================================================================================
  [GRAPHIQUE] DATAINSIGHT PRO — Plateforme Professionnelle d'Analyse de Données
  PARTIE 1 : Setup Professionnel + Découverte du Dataset
================================================================================
  Niveau : Grand débutant en génie logiciel
  Durée estimée : 3-4 heures
  Prérequis : Python installé (3.9+)
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER RÉEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ENTREPRISE FICTIVE : ShopSmart SARL
─────────────────────────────────────
ShopSmart est une PME e-commerce française spécialisée dans la vente de
produits électroniques, vêtements et articles de maison. Fondée en 2018,
elle réalise un chiffre d'affaires annuel de 8 millions d'euros et gère
environ 50 000 commandes par an.

PROBLÉMATIQUE BUSINESS :
  Le directeur général, Marc Dumont, constate depuis 6 mois que certains
  produits se vendent mal dans certaines régions, que le taux de retour
  client augmente, et que les paiements par certains modes sont plus
  risqués. Il vous recrute comme Data Analyst junior.

  Votre mission : analyser 12 mois de données de ventes pour :
    -> Identifier les produits et catégories les plus rentables
    -> Comprendre les comportements d'achat par région
    -> Détecter les anomalies (commandes suspectes, outliers)
    -> Produire un rapport actionnable pour la direction

OBJECTIFS DU PROJET :
  1. Construire une infrastructure d'analyse professionnelle et maintenable
  2. Explorer et comprendre le dataset de ventes (10 000+ lignes)
  3. Produire des insights business à chaque étape
  4. Livrer un rapport final professionnel

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

À la fin de cette partie, vous saurez :

  [OK] Créer un environnement virtuel Python professionnel
  [OK] Installer et vérifier les bibliothèques data science
  [OK] Structurer un projet data avec des dossiers et fichiers organisés
  [OK] Générer un dataset CSV réaliste avec NumPy et Pandas
  [OK] Charger un CSV et effectuer une première exploration
  [OK] Utiliser df.head(), df.info(), df.describe(), df.dtypes
  [OK] Comprendre la pyramide DIKW appliquée à un cas e-commerce
  [OK] Écrire du code Python modulaire et commenté

CONCEPTS DU GUIDE UTILISÉS :
  - Chapitre 1 : Pyramide DIKW, vocabulaire data
  - Chapitre 2 : Types de données (structuré, CSV)
  - Chapitre 3 : Cycle de vie des données (phases 1 et 2)
  - Chapitre 4 : Python bases (variables, listes, dictionnaires, fonctions)
  - Chapitre 5 : Installation de l'environnement, structure de projet

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  DESCRIPTION COMPLÈTE DU DATASET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FICHIER : data/sales_data.csv
TAILLE  : 12 000 lignes × 12 colonnes

COLONNES ET DESCRIPTION :
┌──────────────────┬───────────────┬─────────────────────────────────────────┐
│ Colonne          │ Type          │ Description                             │
├──────────────────┼───────────────┼─────────────────────────────────────────┤
│ order_id         │ int64         │ Identifiant unique de commande          │
│ customer_id      │ int64         │ Identifiant unique du client (1-2000)   │
│ product_name     │ object (str)  │ Nom du produit commandé                 │
│ category         │ object (str)  │ Catégorie produit (4 catégories)        │
│ price            │ float64       │ Prix unitaire en euros                  │
│ quantity         │ int64         │ Quantité commandée (1-10)               │
│ total_amount     │ float64       │ Montant total = price × quantity        │
│ date             │ datetime64    │ Date de la commande (2023-01-01 à fin)  │
│ region           │ object (str)  │ Région de livraison (5 régions France)  │
│ payment_method   │ object (str)  │ Moyen de paiement (4 méthodes)          │
│ customer_age     │ float64       │ Âge du client en années (18-80)         │
│ is_returned      │ bool          │ True si commande retournée              │
└──────────────────┴───────────────┴─────────────────────────────────────────┘

VALEURS DES VARIABLES CATÉGORIELLES :
  category        : ['Electronique', 'Vêtements', 'Maison', 'Livres']
  region          : ['Île-de-France', 'Auvergne-RA', 'PACA', 'Occitanie', 'Bretagne']
  payment_method  : ['Carte', 'PayPal', 'Virement', 'Chèque']

PROBLÈMES INTENTIONNELS (pour les exercices de nettoyage) :
  - ~3% de valeurs manquantes dans customer_age
  - ~2% de valeurs manquantes dans price
  - Quelques outliers dans price (commandes VIP très élevées)
  - Quelques doublons accidentels (~50 lignes)

CAS D'USAGE BUSINESS :
  -> Qui sont nos meilleurs clients ? (customer_id, total_amount)
  -> Quels produits génèrent le plus de CA ? (product_name, total_amount)
  -> Dans quelle région vendons-nous le plus ? (region, total_amount)
  -> Quel mode de paiement est associé aux retours ? (payment_method, is_returned)
  -> Y a-t-il une saisonnalité des ventes ? (date, total_amount)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  ARCHITECTURE DU PROJET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

python_data_project/
│
├── data/                      <- Toutes les données (ne jamais committer en Git!)
│   └── sales_data.csv         <- Dataset principal (généré en Partie 1)
│
├── notebooks/                 <- Exploration interactive (Jupyter)
│   └── exploration.ipynb      <- Notebook d'exploration (Partie 1 & 2)
│
├── src/                       <- Code source Python réutilisable
│   ├── __init__.py            <- Rend src/ un package Python importable
│   ├── data_loader.py         <- Fonctions de chargement du CSV
│   ├── data_cleaning.py       <- Fonctions de nettoyage (NaN, doublons, outliers)
│   ├── analysis.py            <- Fonctions d'analyse et statistiques
│   ├── visualization.py       <- Fonctions de génération des graphiques
│   └── utils.py               <- Fonctions utilitaires communes
│
├── reports/                   <- Livrables finaux
│   └── final_report.md        <- Rapport de synthèse (Partie 8)
│
├── requirements.txt           <- Liste des dépendances Python
└── main.py                    <- Point d'entrée principal du projet

RÔLE DE CHAQUE FICHIER :
  data_loader.py   : Responsable UNIQUEMENT du chargement. Rien d'autre.
                     -> Principe SRP (Single Responsibility Principle)
  data_cleaning.py : Toutes les transformations et corrections de données.
  analysis.py      : Calculs, agrégations, statistiques, KPIs.
  visualization.py : Génération des graphiques (Matplotlib, Seaborn).
  utils.py         : Décorateurs, formatters, fonctions partagées.

BONNE PRATIQUE : UN MODULE = UNE RESPONSABILITÉ
  Ne jamais mélanger le chargement et l'analyse dans le même fichier.
  Cela rend le code plus testable, maintenable et réutilisable.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  THÉORIE APPLIQUÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PYRAMIDE DIKW APPLIQUÉE À SHOPMART :
  DATA        : 12 000 lignes brutes dans le CSV
  INFORMATION : "La région Île-de-France représente 35% du CA total"
  KNOWLEDGE   : "Les ventes IDF explosent en novembre-décembre (Black Friday)"
  WISDOM      : "Augmenter le stock en IDF dès octobre, lancer les promos le 1er nov"

POURQUOI UN ENVIRONNEMENT VIRTUEL ?
  Imaginez deux projets sur votre PC :
    Projet A (2022) -> pandas==1.3.0
    Projet B (2024) -> pandas==2.1.0
  Sans environnement virtuel -> conflit impossible à résoudre.
  Avec venv -> chaque projet a ses propres versions isolées.

TYPES DE DONNÉES DANS CE PROJET :
  order_id    -> int64     (entier, pas besoin de float)
  price       -> float64   (euros avec centimes, ex: 29.99)
  date        -> datetime  (JAMAIS laisser en object/string !)
  category    -> category  (optimise la mémoire de 80%)
  is_returned -> bool      (True/False uniquement)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  IMPLÉMENTATION COMPLÈTE — CODE LIGNE PAR LIGNE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

══════════════════════════════════════════════════════════════════
ÉTAPE A : INSTALLATION DE L'ENVIRONNEMENT (Terminal / CMD)
══════════════════════════════════════════════════════════════════

# Étape 1 : Vérifier que Python est installé
python --version
# -> Doit afficher : Python 3.9.x ou supérieur

# Étape 2 : Créer le dossier du projet
mkdir python_data_project
cd python_data_project

# Étape 3 : Créer l'environnement virtuel
# "venv" est le module standard Python pour créer des environnements isolés
# "data_env" est le nom du dossier qui contiendra l'environnement
python -m venv data_env

# Étape 4 : Activer l'environnement (OBLIGATOIRE avant d'installer quoi que ce soit)
# Sur Windows :
data_env\Scripts\activate
# Sur macOS / Linux :
source data_env/bin/activate
# -> Vous devez voir (data_env) au début de votre ligne de commande

# Étape 5 : Créer les dossiers du projet
mkdir data notebooks src reports

# Étape 6 : Créer les fichiers Python vides
# Windows :
type nul > src\__init__.py
type nul > src\data_loader.py
type nul > src\data_cleaning.py
type nul > src\analysis.py
type nul > src\visualization.py
type nul > src\utils.py
type nul > main.py
# macOS/Linux :
touch src/__init__.py src/data_loader.py src/data_cleaning.py
touch src/analysis.py src/visualization.py src/utils.py main.py


══════════════════════════════════════════════════════════════════
FICHIER : requirements.txt
══════════════════════════════════════════════════════════════════

# Copiez ce contenu dans requirements.txt
numpy==1.26.2
pandas==2.1.3
matplotlib==3.8.2
seaborn==0.13.0
scipy==1.11.4
jupyter==1.0.0
openpyxl==3.1.2

# Installation de toutes les bibliothèques :
pip install -r requirements.txt
# -> pip parcourt le fichier et installe chaque bibliothèque listée


══════════════════════════════════════════════════════════════════
FICHIER : src/utils.py — Fonctions utilitaires communes
══════════════════════════════════════════════════════════════════

"""
utils.py — Fonctions utilitaires partagées entre tous les modules.

Ce module contient des outils génériques réutilisables :
  - Décorateur de mesure du temps
  - Formateur de nombres
  - Logger simple
"""

import time           # Module standard Python pour mesurer le temps
import functools      # Module pour les décorateurs (functools.wraps)
from pathlib import Path  # Gestion moderne des chemins de fichiers


def mesurer_temps(func):
    """
    Décorateur qui affiche le temps d'exécution d'une fonction.

    Un décorateur "enveloppe" une fonction pour ajouter un comportement
    sans modifier la fonction elle-même.

    Utilisation :
        @mesurer_temps
        def ma_fonction():
            ...

    Args:
        func : La fonction à décorer (passée automatiquement)

    Returns:
        La même fonction, avec le chronomètre en plus
    """
    # @functools.wraps(func) préserve le nom et la docstring de func
    # Sans ça, func.__name__ retournerait "wrapper" au lieu du vrai nom
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # *args    : arguments positionnels (ex: ma_fonction(1, 2))
        # **kwargs : arguments nommés (ex: ma_fonction(nom="Alice"))

        debut = time.perf_counter()      # Démarrer le chronomètre (haute précision)
        resultat = func(*args, **kwargs) # Exécuter la vraie fonction
        fin = time.perf_counter()        # Arrêter le chronomètre
        duree = fin - debut              # Calculer la durée

        # Afficher le temps avec 4 décimales
        print(f"[[TEMPS] TIMER] {func.__name__} : {duree:.4f}s")

        return resultat  # Retourner le résultat de la vraie fonction
    return wrapper       # Retourner la fonction "enveloppée"


def formater_euros(montant):
    """
    Formate un nombre en euros lisible.

    Exemple : 1234567.89 -> "1 234 567,89 €"

    Args:
        montant (float) : Le montant en euros

    Returns:
        str : Le montant formaté
    """
    # isinstance() vérifie si montant est un int ou un float
    if not isinstance(montant, (int, float)):
        return "N/A"  # Valeur non applicable si ce n'est pas un nombre

    # f-string avec formatage :
    # {:,.2f} -> séparateurs de milliers (,) et 2 décimales (.2f)
    # .replace(",", " ") -> remplace les , anglophones par des espaces français
    # .replace(".", ",") -> remplace le . décimal par une virgule française
    montant_formate = f"{montant:,.2f}"
    montant_formate = montant_formate.replace(",", " ").replace(".", ",")
    return f"{montant_formate} €"


def verifier_fichier(chemin):
    """
    Vérifie qu'un fichier existe avant de tenter de le lire.

    Args:
        chemin (str) : Le chemin du fichier à vérifier

    Returns:
        Path : L'objet Path du fichier

    Raises:
        FileNotFoundError : Si le fichier n'existe pas
    """
    p = Path(chemin)  # Convertir le string en objet Path

    # .exists() retourne True si le fichier ou dossier existe
    if not p.exists():
        # raise = lancer une exception qui stoppe l'exécution
        raise FileNotFoundError(
            f"Fichier introuvable : {chemin}\n"
            f"Vérifiez que vous êtes dans le bon répertoire."
        )

    # .is_file() vérifie que c'est bien un fichier (pas un dossier)
    if not p.is_file():
        raise ValueError(f"'{chemin}' est un dossier, pas un fichier.")

    return p  # Retourner l'objet Path pour utilisation ultérieure


def afficher_separateur(titre="", largeur=60, caractere="═"):
    """
    Affiche un séparateur visuel dans la console.

    Args:
        titre   (str) : Titre optionnel au centre du séparateur
        largeur (int) : Largeur totale du séparateur
        caractere (str) : Caractère utilisé pour le séparateur
    """
    if titre:
        # Centrer le titre dans une ligne de la largeur donnée
        # .center(n, c) centre le texte avec le caractère c comme remplissage
        ligne = f" {titre} ".center(largeur, caractere)
    else:
        # Créer une ligne pleine de largeur caractères
        ligne = caractere * largeur

    print(ligne)


# Test direct du module (s'exécute seulement si on lance ce fichier directement)
# "if __name__ == '__main__'" est une convention Python :
# -> Quand on lance "python utils.py" directement : __name__ == "__main__" -> True
# -> Quand on importe : "import utils" -> __name__ == "utils" -> False
if __name__ == "__main__":
    afficher_separateur("TEST DES UTILITAIRES")

    # Test du formateur
    print(formater_euros(1234567.89))   # -> 1 234 567,89 €
    print(formater_euros(0))            # -> 0,00 €
    print(formater_euros("oops"))       # -> N/A

    # Test du décorateur
    @mesurer_temps
    def calcul_long():
        total = sum(range(1_000_000))  # 1 million d'additions
        return total

    resultat = calcul_long()
    print(f"Résultat : {resultat}")

    afficher_separateur()


══════════════════════════════════════════════════════════════════
FICHIER : generate_dataset.py — Génération du dataset CSV
══════════════════════════════════════════════════════════════════
# Ce fichier sert à créer le fichier data/sales_data.csv.
# Exécutez-le UNE SEULE FOIS : python generate_dataset.py

"""
generate_dataset.py

Génère un dataset réaliste de ventes e-commerce pour le projet DataInsight Pro.
12 000 lignes, 12 colonnes, avec des anomalies intentionnelles pour les exercices.
"""

import numpy as np        # Bibliothèque de calcul numérique (tableaux, random)
import pandas as pd       # Bibliothèque de manipulation de données tabulaires
from pathlib import Path  # Gestion des chemins de fichiers (multiplateforme)
from datetime import datetime, timedelta  # Manipulation des dates


# ─── CONFIGURATION DU GÉNÉRATEUR ─────────────────────────────────────────────

# np.random.seed() fixe la "graine" du générateur aléatoire.
# Avec la même graine, les mêmes nombres aléatoires sont générés à chaque exécution.
# C'est ESSENTIEL pour la reproductibilité : votre collègue obtiendra le même dataset.
np.random.seed(42)

# Constante : nombre total de commandes à générer
N = 12_000  # 12 000 lignes (le _ est un séparateur visuel autorisé en Python)

# ─── DÉFINITION DES DONNÉES DE RÉFÉRENCE ─────────────────────────────────────

# Dictionnaire produits avec leurs prix unitaires et catégories
# Structure : { nom_produit : (prix_unitaire, catégorie) }
CATALOGUE = {
    # Électronique (prix élevés)
    "Smartphone Samsung A54": (429.99, "Electronique"),
    "Laptop Dell Inspiron":   (799.99, "Electronique"),
    "Casque Sony WH1000":     (249.99, "Electronique"),
    "Tablette iPad Air":      (699.99, "Electronique"),
    "Montre connectée Fitbit":(179.99, "Electronique"),

    # Vêtements (prix moyens)
    "T-shirt coton bio":      (24.99,  "Vêtements"),
    "Jean slim Levi's":       (89.99,  "Vêtements"),
    "Veste polaire North Face":(129.99, "Vêtements"),
    "Robe été fleurie":       (49.99,  "Vêtements"),
    "Sneakers Nike Air":      (119.99, "Vêtements"),

    # Maison (prix variés)
    "Cafetière Nespresso":    (149.99, "Maison"),
    "Robot cuisine Kitchenaid":(349.99,"Maison"),
    "Aspirateur Dyson V11":   (499.99, "Maison"),
    "Coussin décoratif":      (19.99,  "Maison"),
    "Lampe de bureau LED":    (59.99,  "Maison"),

    # Livres (prix bas)
    "Clean Code (Martin)":    (34.99,  "Livres"),
    "Python pour les nuls":   (29.99,  "Livres"),
    "Data Science Handbook":  (45.99,  "Livres"),
    "Le Petit Prince":        (7.99,   "Livres"),
    "Atomic Habits":          (17.99,  "Livres"),
}

# Extraire les listes de produits et leurs propriétés
PRODUITS   = list(CATALOGUE.keys())      # Liste de tous les noms de produits
PRIX_DICT  = {p: v[0] for p, v in CATALOGUE.items()}  # { produit: prix }
CAT_DICT   = {p: v[1] for p, v in CATALOGUE.items()}  # { produit: catégorie }

# Régions françaises avec leurs poids (probabilités de vente)
REGIONS = ["Île-de-France", "Auvergne-RA", "PACA", "Occitanie", "Bretagne"]
POIDS_REGIONS = [0.35, 0.25, 0.18, 0.12, 0.10]
# Île-de-France = 35% des commandes (plus peuplée)
# sum([0.35, 0.25, 0.18, 0.12, 0.10]) = 1.0 (obligatoire pour np.random.choice)

MODES_PAIEMENT = ["Carte", "PayPal", "Virement", "Chèque"]
POIDS_PAIEMENT = [0.55, 0.30, 0.10, 0.05]
# La carte représente 55% des paiements


# ─── GÉNÉRATION DES COLONNES ─────────────────────────────────────────────────

print("Génération du dataset ShopSmart...")
print(f"  Nombre de lignes : {N:,}")

# COLONNE 1 : order_id (identifiants uniques de commande)
# range(10001, 10001 + N) génère les entiers de 10001 à 22001
order_ids = list(range(10001, 10001 + N))

# COLONNE 2 : customer_id (identifiants clients, certains commandent plusieurs fois)
# np.random.randint(a, b, n) génère n entiers entre a (inclus) et b (exclus)
customer_ids = np.random.randint(1, 2001, N)  # 2000 clients distincts

# COLONNE 3 : product_name (choisir parmi le catalogue)
# np.random.choice(liste, n) choisit n éléments au hasard (avec remplacement)
produits_choisis = np.random.choice(PRODUITS, N)

# COLONNE 4 : category (déduite du produit choisi)
# [expression for x in iterable] = list comprehension
# Pour chaque produit choisi, on récupère sa catégorie dans CAT_DICT
categories = [CAT_DICT[p] for p in produits_choisis]

# COLONNE 5 : price (prix de base + légère variation aléatoire)
# Pour chaque produit, on récupère son prix catalogue
prix_base = np.array([PRIX_DICT[p] for p in produits_choisis])
# np.random.normal(0, 2, N) génère N valeurs ~ N(0, 2) (bruit gaussien)
# .clip(-5, 5) limite les variations entre -5€ et +5€ (écart promotionnel)
prix_variation = np.random.normal(0, 2, N).clip(-5, 5)
prix = (prix_base + prix_variation).round(2)  # Arrondi à 2 décimales

# COLONNE 6 : quantity (quantité entre 1 et 10)
# zipf est une loi statistique qui favorise les petites valeurs
# (majorité des commandes = 1-3 articles, quelques rares à 8-10)
quantites = np.random.zipf(2, N).clip(1, 10).astype(int)
# .clip(1, 10) : garantit que les valeurs restent entre 1 et 10
# .astype(int) : convertit en entiers (zipf retourne des float)

# COLONNE 7 : total_amount (montant total = prix × quantité)
# Opération vectorisée : NumPy multiplie les arrays élément par élément
total_amounts = (prix * quantites).round(2)

# COLONNE 8 : date (dates sur 12 mois en 2023)
# pd.date_range génère une séquence de dates
date_debut = datetime(2023, 1, 1)
date_fin   = datetime(2023, 12, 31)
nb_jours   = (date_fin - date_debut).days  # Nombre de jours dans l'intervalle

# Ajouter un nombre aléatoire de jours à la date de début
jours_aleatoires = np.random.randint(0, nb_jours + 1, N)
dates = [date_debut + timedelta(days=int(j)) for j in jours_aleatoires]
# timedelta(days=n) représente une durée de n jours

# COLONNE 9 : region
regions = np.random.choice(REGIONS, N, p=POIDS_REGIONS)
# p=POIDS_REGIONS : probabilités pondérées

# COLONNE 10 : payment_method
paiements = np.random.choice(MODES_PAIEMENT, N, p=POIDS_PAIEMENT)

# COLONNE 11 : customer_age (âge entre 18 et 80 ans)
ages = np.random.normal(38, 12, N).clip(18, 80).round(0).astype(float)
# normal(38, 12) : moyenne 38 ans, écart-type 12 ans
# .clip(18, 80) : pas de mineurs, pas de centenaires
# On laisse en float64 pour pouvoir ajouter des NaN plus tard

# COLONNE 12 : is_returned (retours client, ~8% des commandes)
# np.random.random(N) génère N flottants entre 0 et 1
# < 0.08 : True si la valeur < 8% (probabilité de retour)
is_returned = np.random.random(N) < 0.08
# is_returned est un array de booléens (True/False)


# ─── ASSEMBLAGE DU DATAFRAME ──────────────────────────────────────────────────

# pd.DataFrame({...}) crée un DataFrame depuis un dictionnaire
# Clé = nom de la colonne, Valeur = array/liste des données
df = pd.DataFrame({
    "order_id":       order_ids,
    "customer_id":    customer_ids,
    "product_name":   produits_choisis,
    "category":       categories,
    "price":          prix,
    "quantity":       quantites,
    "total_amount":   total_amounts,
    "date":           dates,
    "region":         regions,
    "payment_method": paiements,
    "customer_age":   ages,
    "is_returned":    is_returned,
})

print(f"  DataFrame créé : {df.shape}")  # (12000, 12)


# ─── INTRODUCTION D'ANOMALIES RÉALISTES ──────────────────────────────────────
# Ces anomalies seront détectées et corrigées en Partie 3 (nettoyage)

# 1. Valeurs manquantes dans customer_age (~3%)
# np.random.choice(N, 360, replace=False) : choisir 360 indices SANS remise
idx_nan_age = np.random.choice(N, 360, replace=False)
df.loc[idx_nan_age, "customer_age"] = np.nan
# np.nan = Not a Number, la représentation de "valeur manquante" en NumPy/Pandas

# 2. Valeurs manquantes dans price (~2%)
idx_nan_price = np.random.choice(N, 240, replace=False)
df.loc[idx_nan_price, "price"] = np.nan

# 3. Quelques outliers dans total_amount (commandes professionnelles/B2B)
idx_outliers = np.random.choice(N, 30, replace=False)
df.loc[idx_outliers, "total_amount"] = np.random.uniform(5000, 15000, 30).round(2)

# 4. Doublons (~50 lignes copiées)
# .sample(50) : choisir 50 lignes au hasard
# pd.concat : coller verticalement df avec les 50 doublons
duplicates = df.sample(50, random_state=1)
df = pd.concat([df, duplicates], ignore_index=True)
# ignore_index=True : réinitialiser les indices (0, 1, 2, ... sans trous)

print(f"  Après ajout de doublons : {df.shape}")  # (12050, 12)

# 5. Corriger les types pour optimiser la mémoire
# Les colonnes catégorielles avec peu de valeurs uniques -> type "category"
df["category"]       = df["category"].astype("category")
df["region"]         = df["region"].astype("category")
df["payment_method"] = df["payment_method"].astype("category")
# La conversion en "category" réduit l'utilisation mémoire de 80% !

# 6. Convertir les dates en datetime64
df["date"] = pd.to_datetime(df["date"])


# ─── SAUVEGARDE ───────────────────────────────────────────────────────────────

# Créer le dossier data/ s'il n'existe pas
# parents=True : crée aussi les dossiers parents si nécessaire
# exist_ok=True : ne plante pas si le dossier existe déjà
Path("data").mkdir(parents=True, exist_ok=True)

# Sauvegarder en CSV
# index=False : ne pas sauvegarder l'index (0, 1, 2...) comme colonne
df.to_csv("data/sales_data.csv", index=False, encoding="utf-8")

print(f"  [OK] Dataset sauvegardé : data/sales_data.csv")
print(f"  Taille du fichier : {Path('data/sales_data.csv').stat().st_size / 1024:.1f} KB")


══════════════════════════════════════════════════════════════════
FICHIER : src/data_loader.py — Chargement du CSV
══════════════════════════════════════════════════════════════════

"""
data_loader.py

Responsabilité : Charger les données depuis les fichiers sources.
Ce module ne fait RIEN d'autre que charger. Pas de nettoyage, pas d'analyse.
"""

import pandas as pd      # Manipulation de données tabulaires
import numpy as np       # Calcul numérique
from pathlib import Path  # Gestion des chemins multiplateforme

# Import depuis notre propre module utils
# Le point (.) signifie "dans le même package" (le dossier src/)
from .utils import mesurer_temps, verifier_fichier, afficher_separateur


@mesurer_temps   # <- Ce décorateur affichera le temps d'exécution automatiquement
def charger_dataset(chemin="data/sales_data.csv", verbose=True):
    """
    Charge le dataset de ventes depuis un fichier CSV.

    Cette fonction gère :
      - La vérification que le fichier existe
      - Le chargement avec les bons types de données
      - L'affichage d'un rapport initial si verbose=True

    Args:
        chemin  (str)  : Chemin vers le fichier CSV
        verbose (bool) : Afficher ou non les informations de chargement

    Returns:
        pd.DataFrame : Le dataset chargé (brut, non nettoyé)

    Raises:
        FileNotFoundError : Si le fichier CSV n'existe pas
    """

    # Étape 1 : Vérifier que le fichier existe (lève une erreur si absent)
    verifier_fichier(chemin)

    if verbose:
        afficher_separateur("CHARGEMENT DU DATASET")
        print(f"  Fichier : {chemin}")

    # Étape 2 : Chargement avec pd.read_csv()
    # parse_dates : convertit automatiquement la colonne "date" en datetime64
    # dtype : forcer certains types à la lecture pour éviter les conversions ultérieures
    df = pd.read_csv(
        chemin,
        parse_dates=["date"],    # Convertir "date" en datetime64 dès le chargement
        dtype={
            "order_id":    "int64",    # Identifiant commande : entier
            "customer_id": "int64",    # Identifiant client : entier
            "quantity":    "int64",    # Quantité : entier
        },
        encoding="utf-8"         # Encodage UTF-8 (supporte les accents français)
    )

    # Étape 3 : Optimisation mémoire (convertir les colonnes répétitives en category)
    for col in ["category", "region", "payment_method", "product_name"]:
        # Seulement si la colonne existe (sécurité)
        if col in df.columns:
            df[col] = df[col].astype("category")
            # "category" Pandas = stocke les valeurs en entiers + table de correspondance
            # Ex: ["Carte", "PayPal", "Carte"] -> [0, 1, 0] + {"0": "Carte", "1": "PayPal"}

    if verbose:
        # Calculer l'utilisation mémoire
        # memory_usage(deep=True) : calcule la vraie taille en mémoire
        # .sum() : additionner la mémoire de toutes les colonnes
        memoire_mb = df.memory_usage(deep=True).sum() / 1024 / 1024
        print(f"  Lignes    : {df.shape[0]:,}")   # {:,} = séparateur de milliers
        print(f"  Colonnes  : {df.shape[1]}")
        print(f"  Mémoire   : {memoire_mb:.1f} MB")

    return df  # Retourner le DataFrame chargé


def afficher_apercu(df):
    """
    Affiche un aperçu complet du DataFrame pour découverte initiale.

    Montre les premières lignes, les types, et les statistiques de base.

    Args:
        df (pd.DataFrame) : Le DataFrame à inspecter
    """
    afficher_separateur("APERÇU DU DATASET")

    # 1. Premières lignes
    print("\n--- 5 premières lignes ---")
    print(df.head())
    # head(n) : retourne les n premières lignes (5 par défaut)

    # 2. Types de données
    print("\n--- Types de données ---")
    print(df.dtypes)
    # dtypes : Series avec le type de chaque colonne

    # 3. Valeurs manquantes
    print("\n--- Valeurs manquantes ---")
    nan_counts = df.isnull().sum()
    # df.isnull() : DataFrame de booléens (True si NaN)
    # .sum() : additionne les True (= compte les NaN) par colonne
    nan_pct = (nan_counts / len(df) * 100).round(2)
    # len(df) : nombre de lignes du DataFrame
    nan_report = pd.DataFrame({
        "NaN count": nan_counts,
        "NaN %": nan_pct
    })
    # Afficher seulement les colonnes avec au moins 1 NaN
    print(nan_report[nan_report["NaN count"] > 0])

    # 4. Statistiques descriptives (numériques seulement)
    print("\n--- Statistiques descriptives ---")
    print(df.describe())
    # describe() : count, mean, std, min, 25%, 50%, 75%, max
    # pour toutes les colonnes numériques

    # 5. Doublons
    n_doublons = df.duplicated().sum()
    # duplicated() : True si la ligne est une copie exacte d'une ligne précédente
    print(f"\n--- Doublons : {n_doublons} ({n_doublons/len(df)*100:.1f}%) ---")


# ─── TEST DIRECT ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
    # Ce bloc s'exécute seulement si on lance : python src/data_loader.py
    # Il sert à tester rapidement le module de façon indépendante

    # Charger les données
    df = charger_dataset()

    # Afficher l'aperçu complet
    afficher_apercu(df)


══════════════════════════════════════════════════════════════════
FICHIER : main.py — Point d'entrée principal
══════════════════════════════════════════════════════════════════

"""
main.py — Point d'entrée du projet DataInsight Pro

Ce fichier orchestre l'ensemble du pipeline d'analyse :
  1. Chargement des données
  2. Nettoyage
  3. Analyse
  4. Visualisation
  5. Rapport

Pour exécuter : python main.py
"""

# Imports des modules Python standard
import sys           # Gestion du système (version Python, etc.)
from pathlib import Path  # Gestion des chemins

# Imports de nos modules personnalisés (le dossier src/ doit être dans le chemin)
# sys.path.insert(0, str(Path(__file__).parent))
# -> Ajoute le dossier courant au chemin de recherche Python

# En Python, pour importer depuis src/, on doit être dans le dossier racine du projet
from src.data_loader import charger_dataset, afficher_apercu
from src.utils import afficher_separateur, formater_euros


def main():
    """
    Fonction principale du pipeline d'analyse DataInsight Pro.
    """
    afficher_separateur("[GRAPHIQUE] DATAINSIGHT PRO — ShopSmart Analytics", largeur=65)
    print(f"  Python version : {sys.version.split()[0]}")

    # === ÉTAPE 1 : CHARGEMENT ===
    afficher_separateur("ÉTAPE 1 : Chargement des données")
    df_brut = charger_dataset("data/sales_data.csv", verbose=True)

    # Vérification rapide
    print(f"\n  [OK] Dataset chargé : {df_brut.shape[0]:,} lignes × {df_brut.shape[1]} colonnes")

    # Afficher les premières insights basiques
    afficher_separateur("APERÇU RAPIDE — INSIGHTS INITIAUX")
    ca_total = df_brut["total_amount"].sum()
    print(f"\n  Chiffre d'affaires total : {formater_euros(ca_total)}")
    print(f"  Panier moyen            : {formater_euros(df_brut['total_amount'].mean())}")
    print(f"  Période couverte        : {df_brut['date'].min().date()} -> {df_brut['date'].max().date()}")
    print(f"  Clients uniques         : {df_brut['customer_id'].nunique():,}")
    # nunique() = Number of Unique values = compte les valeurs distinctes

    afficher_separateur("FIN DE LA PARTIE 1 — SETUP RÉUSSI [OK]", largeur=65)
    print("\n  Prochaine étape : Partie 2 — Chargement et manipulation avec Pandas")


# Point d'entrée standard Python
if __name__ == "__main__":
    main()


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  ANALYSE ET INTERPRÉTATION DES PREMIERS RÉSULTATS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Après exécution de python main.py, vous devriez obtenir :

  Chiffre d'affaires total : environ 6 500 000,00 €
  Panier moyen             : environ 54,17 €
  Période couverte         : 2023-01-01 -> 2023-12-31
  Clients uniques          : environ 1 987

INTERPRÉTATION BUSINESS :
  -> Le CA de 6,5M€ est cohérent avec notre entreprise fictive (8M€ annuel)
  -> Le panier moyen de 54€ est raisonnable pour un mix électro/vêtements/livres
  -> 1987 clients distincts sur 2000 créés = bonne répartition (peu de clients inactifs)

SIGNAUX D'ALERTE :
  -> 360 âges manquants (3%) -> à corriger en Partie 3
  -> 240 prix manquants (2%) -> à corriger en Partie 3
  -> 50 doublons (~0.4%) -> à supprimer en Partie 3
  -> 30 outliers dans total_amount -> à traiter en Partie 3

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8⃣  BONNES PRATIQUES PROFESSIONNELLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. NAMING CONVENTIONS PYTHON :
   -> Variables : snake_case (df_brut, total_amount, customer_id)
   -> Constantes : UPPER_CASE (N, REGIONS, CATALOGUE)
   -> Fonctions : snake_case, verbes d'action (charger_dataset, afficher_apercu)
   -> Classes : PascalCase (DataLoader, SalesAnalyzer)

2. DOCUMENTATION :
   -> Toujours écrire une docstring pour chaque fonction
   -> Commenter le "pourquoi", pas le "quoi" (le code se lit déjà)
   -> Exemple : # np.nan = valeur manquante (explique POURQUOI on l'utilise)

3. SÉPARATION DES RESPONSABILITÉS :
   -> data_loader.py : UNIQUEMENT charger
   -> Ne jamais nettoyer dans le loader (c'est le rôle de data_cleaning.py)

4. REPRODUCTIBILITÉ :
   -> Toujours fixer np.random.seed(42) en haut du script de génération
   -> Toujours sauvegarder les données générées (ne pas régénérer à chaque run)

5. VERSIONNING DES DONNÉES :
   -> data/raw/     : données brutes originales -> JAMAIS modifier
   -> data/clean/   : données après nettoyage
   -> Utiliser un timestamp dans le nom si plusieurs versions

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9⃣  ERREURS FRÉQUENTES À ÉVITER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ERREUR 1 : Oublier d'activer l'environnement virtuel
  Symptôme : ModuleNotFoundError: No module named 'pandas'
  Solution : source data_env/bin/activate (puis pip install -r requirements.txt)

ERREUR 2 : Lancer main.py depuis le mauvais dossier
  Symptôme : FileNotFoundError: data/sales_data.csv not found
  Solution : Toujours lancer depuis python_data_project/
             Vérifier avec : import os; print(os.getcwd())

ERREUR 3 : Modifier les données brutes originales
  Symptôme : Impossible de revenir en arrière après une modification
  Solution : TOUJOURS travailler sur df.copy() et garder le CSV original intact

ERREUR 4 : Oublier parse_dates lors du chargement
  Symptôme : La colonne date est de type "object" (string), pas datetime
  Solution : pd.read_csv("...", parse_dates=["date"])
  Conséquence si oublié : impossible de faire df["date"].dt.month plus tard

ERREUR 5 : Confondre .sum() et .count()
  .count() : compte les valeurs NON-nulles
  .sum()   : additionne les valeurs (y compris booléens : True=1, False=0)
  Exemple : df["is_returned"].sum() = nombre de retours (les True valent 1)
            df["is_returned"].count() = nombre de lignes non-NaN (pas les retours !)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────────────────

Exercice 1.1 — Structure du projet
  Créez la structure de dossiers complète du projet et vérifiez
  que tous les fichiers existent avec os.listdir() ou Path(".").rglob("*.py")

Exercice 1.2 — Validation de l'environnement
  Écrivez un script validate_env.py qui importe chaque bibliothèque et
  affiche sa version. Affichez "[OK] OK" ou "[X] MANQUANT" pour chacune.

Exercice 1.3 — Premier chargement
  Lancez generate_dataset.py pour créer le CSV, puis chargez-le avec
  pd.read_csv() et affichez : shape, dtypes, les 3 premières lignes.

── NIVEAU INTERMÉDIAIRE ──────────────────────────────────────────────────────

Exercice 1.4 — Pyramide DIKW personnelle
  En regardant uniquement les premières lignes du CSV (head(10)),
  construisez votre propre pyramide DIKW :
  DATA -> INFORMATION -> KNOWLEDGE -> WISDOM

Exercice 1.5 — Amélioration du data_loader
  Ajoutez à charger_dataset() un paramètre nrows=None qui permet de
  charger seulement les N premières lignes (utile pour tester rapidement
  sur de gros datasets).

Exercice 1.6 — Formatage personnalisé
  Modifiez formater_euros() dans utils.py pour qu'elle accepte aussi
  de formater des pourcentages : formater_pct(0.1234) -> "12,34 %"

── NIVEAU AVANCÉ ────────────────────────────────────────────────────────────

Exercice 1.7 — DataLoader orienté objet
  Transformez les fonctions de data_loader.py en une classe DataLoader
  avec un attribut self.df et des méthodes charger(), afficher(), valider().
  La méthode valider() doit vérifier que toutes les colonnes attendues
  sont présentes et lever une ValueError si une est manquante.

Exercice 1.8 — Comparaison mémoire
  Chargez le dataset une fois SANS astype("category") et une fois AVEC.
  Comparez l'usage mémoire avec df.memory_usage(deep=True).sum().
  Quel est le gain en pourcentage ?

Exercice 1.9 — Auto-documentation
  Ajoutez à main.py une fonction generer_metadata() qui crée un fichier
  data/metadata.json contenant :
  - date de génération (datetime.now())
  - nombre de lignes, colonnes
  - liste des colonnes avec leur type
  - hash MD5 du fichier CSV (pour détecter les modifications)
  Indice : import hashlib; hashlib.md5(open("fichier","rb").read()).hexdigest()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉ ULTRA DÉTAILLÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── CORRIGÉ EXERCICE 1.2 — validate_env.py ──────────────────────────────────

"""
validate_env.py — Vérifie que l'environnement data science est complet.
Lancez avec : python validate_env.py
"""


def verifier_module(nom_module, alias=None):
    """
    Tente d'importer un module et retourne True si succès.

    Args:
        nom_module (str) : Nom du module Python à importer
        alias (str)      : Alias optionnel (ex: "numpy" avec alias "np")

    Returns:
        bool : True si le module est disponible, False sinon
    """
    try:
        # __import__(nom) équivaut à "import nom"
        # C'est la forme dynamique (le nom est une variable)
        module = __import__(nom_module)

        # Récupérer la version si l'attribut __version__ existe
        # getattr(obj, attr, defaut) = obj.attr si existe, sinon defaut
        version = getattr(module, "__version__", "inconnue")

        print(f"  [OK] {nom_module:<20} version {version}")
        return True

    except ImportError:
        # ImportError est levée quand le module n'est pas installé
        print(f"  [X] {nom_module:<20} ABSENT — installez avec : pip install {nom_module}")
        return False


# Programme principal
print("=" * 55)
print("  VALIDATION DE L'ENVIRONNEMENT DATA SCIENCE")
print("=" * 55)

# Liste des modules à vérifier (ordre d'importance)
modules_requis = [
    "numpy",
    "pandas",
    "matplotlib",
    "seaborn",
    "scipy",
    "sklearn",      # scikit-learn s'importe comme sklearn
    "jupyter",
    "openpyxl",
]

# Tester chaque module et compter les succès
resultats = [verifier_module(m) for m in modules_requis]
# [expression for x in liste] = list comprehension
# résultats = [True, True, False, ...] selon ce qui est installé

nb_ok = sum(resultats)       # sum([True, True, False, True]) = 3
nb_total = len(resultats)    # len([...]) = 8

print("=" * 55)
if nb_ok == nb_total:
    print(f"  [OK] Environnement complet ({nb_ok}/{nb_total} modules OK)")
    print("  Prêt pour DataInsight Pro !")
else:
    print(f"  [ATTENTION]  {nb_total - nb_ok} modules manquants ({nb_ok}/{nb_total} OK)")
    print("  Exécutez : pip install -r requirements.txt")


── CORRIGÉ EXERCICE 1.7 — Classe DataLoader ──────────────────────────────

"""
Corrigé de l'exercice 1.7 : DataLoader orienté objet.
"""

import pandas as pd
from pathlib import Path


class DataLoader:
    """
    Chargeur de données orienté objet pour le projet DataInsight Pro.

    Avantages de la classe vs fonctions isolées :
      - État persistant (self.df stocké entre les appels)
      - Méthodes chaînables (return self pour method chaining)
      - Encapsulation (données + méthodes ensemble)
    """

    # Colonnes obligatoires attendues dans le CSV
    # C'est une constante de classe (partagée par toutes les instances)
    COLONNES_REQUISES = [
        "order_id", "customer_id", "product_name", "category",
        "price", "quantity", "total_amount", "date", "region",
        "payment_method", "customer_age", "is_returned"
    ]

    def __init__(self, chemin):
        """
        Initialise le DataLoader avec le chemin du fichier.

        __init__ est le "constructeur" de la classe.
        Il s'exécute automatiquement quand on crée une instance :
        loader = DataLoader("data/sales_data.csv")

        Args:
            chemin (str) : Chemin vers le fichier CSV
        """
        self.chemin = chemin   # self.chemin = attribut d'instance
        self.df = None         # Sera rempli par charger()
        # None indique que les données ne sont pas encore chargées

    def charger(self):
        """
        Charge le fichier CSV dans self.df.

        Returns:
            self : Pour permettre le chaînage de méthodes
                   Exemple : loader.charger().valider().afficher()
        """
        if not Path(self.chemin).exists():
            raise FileNotFoundError(f"Fichier introuvable : {self.chemin}")

        self.df = pd.read_csv(self.chemin, parse_dates=["date"])

        # Optimisation mémoire pour les colonnes catégorielles
        for col in ["category", "region", "payment_method"]:
            if col in self.df.columns:
                self.df[col] = self.df[col].astype("category")

        print(f"[OK] Chargé : {self.df.shape[0]:,} lignes × {self.df.shape[1]} colonnes")
        return self  # Retourner self pour le method chaining

    def valider(self):
        """
        Vérifie que toutes les colonnes requises sont présentes.

        Returns:
            self : Pour le chaînage

        Raises:
            ValueError : Si une colonne requise est absente
        """
        if self.df is None:
            raise RuntimeError("Appelez d'abord charger() avant valider()")

        # set() = ensemble (pas d'ordre, pas de doublons)
        # La différence d'ensembles donne les colonnes manquantes
        colonnes_presentes = set(self.df.columns)
        colonnes_requises  = set(self.COLONNES_REQUISES)
        colonnes_manquantes = colonnes_requises - colonnes_presentes

        if colonnes_manquantes:
            # Si l'ensemble des manquantes n'est pas vide
            raise ValueError(
                f"Colonnes manquantes dans le CSV : {colonnes_manquantes}\n"
                f"Colonnes présentes : {colonnes_presentes}"
            )

        print(f"[OK] Validation réussie : {len(self.COLONNES_REQUISES)} colonnes présentes")
        return self

    def afficher(self):
        """
        Affiche un aperçu du DataFrame chargé.

        Returns:
            self : Pour le chaînage
        """
        if self.df is None:
            print("Aucune donnée chargée. Appelez charger() d'abord.")
            return self

        print(f"\nPremières lignes :")
        print(self.df.head())
        print(f"\nTypes :")
        print(self.df.dtypes)
        return self


# Utilisation avec method chaining :
# loader = DataLoader("data/sales_data.csv").charger().valider().afficher()

── CORRIGÉ EXERCICE 1.8 — Comparaison mémoire ──────────────────────────────

import pandas as pd

# Charger SANS optimisation
df_normal = pd.read_csv("data/sales_data.csv")
mem_normal = df_normal.memory_usage(deep=True).sum()

# Charger AVEC optimisation
df_opti = pd.read_csv("data/sales_data.csv")
for col in ["category", "region", "payment_method", "product_name"]:
    df_opti[col] = df_opti[col].astype("category")
mem_opti = df_opti.memory_usage(deep=True).sum()

gain_pct = (1 - mem_opti / mem_normal) * 100

print(f"Mémoire sans optimisation : {mem_normal / 1024:.1f} KB")
print(f"Mémoire avec optimisation : {mem_opti / 1024:.1f} KB")
print(f"Gain mémoire              : {gain_pct:.1f}%")
# Résultat attendu : gain d'environ 55-70% selon les données


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[IMPORTANT] RÉSUMÉ DE LA PARTIE 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Vous avez accompli :
  [OK] Création d'un environnement virtuel Python professionnel
  [OK] Installation des bibliothèques data science (NumPy, Pandas, Matplotlib...)
  [OK] Structure de projet professionnelle (data/, src/, notebooks/, reports/)
  [OK] Génération d'un dataset CSV réaliste de 12 000 lignes
  [OK] Module data_loader.py modulaire avec docstrings et gestion d'erreurs
  [OK] Module utils.py avec décorateur, formateur et vérificateur
  [OK] Point d'entrée main.py orchestrant le pipeline
  [OK] Compréhension de la pyramide DIKW appliquée à l'e-commerce

PROCHAINE ÉTAPE -> PARTIE 2 : Chargement avancé et manipulation Pandas
  - Sélection et filtrage de colonnes
  - Opérations sur les types de données
  - Création de nouvelles colonnes (feature engineering basique)
  - Tri et sélection avec .loc et .iloc

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 1
================================================================================

================================================================================
  [GRAPHIQUE] DATAINSIGHT PRO — Plateforme Professionnelle d'Analyse de Données
  PARTIE 2 : Chargement Avancé et Manipulation Pandas
================================================================================
  Niveau : Débutant -> Intermédiaire
  Durée estimée : 4-5 heures
  Prérequis : Partie 1 terminée, dataset généré
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Marc Dumont (DG de ShopSmart) revient avec des questions précises :
  "Je veux voir uniquement les commandes d'Île-de-France de plus de 200€."
  "Triez-moi les 10 clients avec les plus gros paniers moyens."
  "Ajoutez une colonne 'mois' pour analyser la saisonnalité."
  "Combien de commandes avons-nous par mode de paiement ?"

Cette partie vous apprend à répondre à ces questions avec Pandas.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  [OK] Maîtriser la sélection de colonnes (une, plusieurs, par type)
  [OK] Utiliser .loc[] et .iloc[] pour accéder aux données
  [OK] Filtrer avec des conditions booléennes simples et composées
  [OK] Créer de nouvelles colonnes par calcul, apply, et np.where
  [OK] Trier et classer les données avec sort_values et nlargest
  [OK] Extraire des informations temporelles depuis datetime (mois, jour, trimestre)
  [OK] Compter les valeurs uniques avec value_counts et nunique
  [OK] Comprendre la différence entre vue et copie (pitfall Pandas)

CONCEPTS DU GUIDE UTILISÉS :
  - Chapitre 15 : Introduction à Pandas, Series vs DataFrame
  - Chapitre 16 : Création, exploration, manipulation des colonnes
  - Chapitre 17 : Indexing avancé (.loc, .iloc, DatetimeIndex)
  - Chapitre 18 : Filtrage booléen, isin, between, query

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  THÉORIE APPLIQUÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SÉRIE vs DATAFRAME — LA DISTINCTION FONDAMENTALE :

  Une Series, c'est une COLONNE :
    df["total_amount"]  -> pd.Series de 12000 éléments
    C'est un tableau 1D avec un index

  Un DataFrame, c'est un TABLEAU 2D :
    df[["total_amount", "region"]]  -> pd.DataFrame avec 2 colonnes
    Note : double [[]] pour obtenir un DataFrame (simple [] donne une Series)

.LOC vs .ILOC — LA RÈGLE À RETENIR :
  .loc  = Labels  (noms d'index, noms de colonnes) -> INCLUSIF
  .iloc = Integers (positions numériques) -> comme Python, EXCLUSIF à droite

OPÉRATEURS BOOLÉENS PANDAS :
  &  = ET  (pas "and" !)
  |  = OU  (pas "or" !)
  ~  = NON (pas "not" !)
  Parenthèses OBLIGATOIRES : (cond1) & (cond2)

ATTENTION VUE vs COPIE :
  df_idf = df[df["region"] == "Île-de-France"]
  df_idf["new_col"] = 1  <- DANGER ! SettingWithCopyWarning !
  
  Solution : df_idf = df[df["region"] == "Île-de-France"].copy()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  IMPLÉMENTATION COMPLÈTE — CODE LIGNE PAR LIGNE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

══════════════════════════════════════════════════════════════════
FICHIER : notebooks/exploration.ipynb (simulé en script Python)
Ou créez ce fichier : src/manipulation_demo.py
══════════════════════════════════════════════════════════════════

"""
manipulation_demo.py — Démonstration complète de la manipulation Pandas
pour le dataset ShopSmart.

Ce fichier correspond au contenu du notebook Jupyter exploration.ipynb.
"""

# ─── IMPORTS ─────────────────────────────────────────────────────────────────
import pandas as pd   # Bibliothèque principale pour les données tabulaires
import numpy as np    # Calcul numérique (utilisé pour np.where, np.nan)

# Import depuis nos modules (on suppose être à la racine du projet)
import sys
sys.path.insert(0, ".")   # Ajouter le dossier courant au chemin Python
from src.data_loader import charger_dataset
from src.utils import afficher_separateur, formater_euros


# ─── CHARGEMENT ──────────────────────────────────────────────────────────────
df = charger_dataset("data/sales_data.csv", verbose=False)
# verbose=False : on ne veut pas le rapport de chargement (déjà vu en Partie 1)

print(f"Dataset chargé : {df.shape}")


# ═════════════════════════════════════════════════════════════════════════════
# SECTION 1 : SÉLECTION DE COLONNES
# ═════════════════════════════════════════════════════════════════════════════

afficher_separateur("SECTION 1 : SÉLECTION DE COLONNES")

# 1.1 Sélectionner UNE colonne -> retourne une Series
serie_prix = df["price"]
# df["price"] : accès par clé, comme un dictionnaire Python
print(type(serie_prix))    # <class 'pandas.core.series.Series'>
print(serie_prix.head(3))  # Affiche les 3 premières valeurs avec leur index

# 1.2 Sélectionner PLUSIEURS colonnes -> retourne un DataFrame
# ATTENTION : double crochets [[...]] obligatoires pour avoir un DataFrame
df_ventes = df[["product_name", "price", "quantity", "total_amount", "region"]]
print(type(df_ventes))     # <class 'pandas.core.frame.DataFrame'>
print(df_ventes.head(3))

# 1.3 Sélectionner les colonnes NUMÉRIQUES automatiquement
# select_dtypes() filtre les colonnes selon leur type
cols_numeriques = df.select_dtypes(include=[np.number])
# include=[np.number] : garder int64, float64, etc.
print(f"\nColonnes numériques ({len(cols_numeriques.columns)}) :")
print(cols_numeriques.columns.tolist())

# 1.4 Sélectionner les colonnes TEXTE/CATÉGORIELLES
cols_texte = df.select_dtypes(include=["object", "category"])
print(f"\nColonnes texte/catégorielles ({len(cols_texte.columns)}) :")
print(cols_texte.columns.tolist())

# 1.5 Exclure des colonnes
df_sans_id = df.drop(columns=["order_id", "customer_id"])
# drop(columns=[...]) : retourne un NOUVEAU DataFrame sans ces colonnes
# Le DataFrame original df est INCHANGÉ (opération non destructive par défaut)
print(f"\nSans colonnes ID : {df_sans_id.shape}")

# 1.6 Vérifier qu'une colonne existe avant d'y accéder
def get_col_safe(df, nom_col):
    """Récupère une colonne avec un message d'erreur clair."""
    if nom_col not in df.columns:
        print(f"[ATTENTION] Colonne '{nom_col}' introuvable. Colonnes disponibles : {df.columns.tolist()}")
        return None
    return df[nom_col]

# Test : colonne existante
col = get_col_safe(df, "total_amount")  # Retourne la Series

# Test : colonne inexistante
col = get_col_safe(df, "revenue")  # Affiche le message d'alerte


# ═════════════════════════════════════════════════════════════════════════════
# SECTION 2 : ACCÈS AVEC .LOC ET .ILOC
# ═════════════════════════════════════════════════════════════════════════════

afficher_separateur("SECTION 2 : .LOC ET .ILOC")

# Rappel : notre index est RangeIndex (0, 1, 2, ..., 12049)
# .loc utilise les LABELS de l'index
# .iloc utilise les POSITIONS (0-based, exclusif à droite)

# 2.1 .loc — accès par label d'index
# Syntaxe : df.loc[index_label, nom_colonne]
premiere_ligne = df.loc[0]
# df.loc[0] : récupère la ligne avec index=0 (toutes les colonnes)
print("\nLigne 0 (via .loc) :")
print(premiere_ligne)

# Valeur unique avec .loc
prix_ligne_5 = df.loc[5, "price"]
# Ligne d'index 5, colonne "price"
print(f"\nPrix de la ligne 5 : {prix_ligne_5}")

# Plusieurs lignes + plusieurs colonnes
sous_ensemble = df.loc[0:4, ["product_name", "total_amount", "region"]]
# 0:4 en .loc : INCLUSIF (0, 1, 2, 3, 4 -> 5 lignes)
print(f"\nLignes 0 à 4, 3 colonnes :\n{sous_ensemble}")

# Condition booléenne avec .loc
commandes_idf = df.loc[df["region"] == "Île-de-France", ["product_name", "total_amount"]]
# df["region"] == "Île-de-France" : Series de booléens
# .loc[masque, colonnes] : lignes où masque=True, colonnes spécifiées
print(f"\nCommandes Île-de-France : {len(commandes_idf)} commandes")

# 2.2 .iloc — accès par position
# Syntaxe : df.iloc[row_position, col_position]

premiere_ligne_iloc = df.iloc[0]
# Même chose que df.loc[0] quand l'index commence à 0

# Sous-ensemble par positions (exclusif à droite !)
sous_set = df.iloc[0:5, 0:4]
# Lignes 0, 1, 2, 3, 4 (5 lignes) ; colonnes 0, 1, 2, 3 (4 colonnes)
print(f"\nPremières 5 lignes, 4 premières colonnes :\n{sous_set}")

# Dernières lignes
dernieres = df.iloc[-5:]
# -5: = de la 5ème en partant de la fin jusqu'à la fin
print(f"\nDernières 5 lignes : {len(dernieres)}")

# 2.3 Modification via .loc (TOUJOURS utiliser .loc pour modifier)
# NE JAMAIS faire : df[masque]["colonne"] = valeur  <- ChainedIndexing !
df_copy = df.copy()   # Travailler sur une copie pour ne pas altérer l'original

# Marquer les commandes de plus de 1000€ comme "VIP"
df_copy.loc[df_copy["total_amount"] > 1000, "segment"] = "VIP"
df_copy.loc[df_copy["total_amount"] <= 1000, "segment"] = "Standard"
# segment est une nouvelle colonne créée au vol
print(f"\nSegmentation créée :\n{df_copy['segment'].value_counts()}")


# ═════════════════════════════════════════════════════════════════════════════
# SECTION 3 : FILTRAGE AVANCÉ
# ═════════════════════════════════════════════════════════════════════════════

afficher_separateur("SECTION 3 : FILTRAGE AVANCÉ")

# 3.1 Filtre simple — une condition
gros_paniers = df[df["total_amount"] > 500]
print(f"\nCommandes > 500€ : {len(gros_paniers)} ({len(gros_paniers)/len(df)*100:.1f}%)")

# 3.2 Filtre composé — ET (&)
# RÈGLE : Parenthèses OBLIGATOIRES autour de chaque condition !
idf_gros = df[(df["region"] == "Île-de-France") & (df["total_amount"] > 500)]
print(f"IDF ET > 500€ : {len(idf_gros)} commandes")

# 3.3 Filtre composé — OU (|)
# Commandes dans le Sud (PACA ou Occitanie)
sud = df[(df["region"] == "PACA") | (df["region"] == "Occitanie")]
print(f"Sud (PACA ou Occitanie) : {len(sud)} commandes")

# Version plus élégante avec .isin() quand il y a plusieurs valeurs
# .isin([...]) : True si la valeur est dans la liste
sud_v2 = df[df["region"].isin(["PACA", "Occitanie"])]
print(f"Sud (via isin) : {len(sud_v2)} commandes")

# 3.4 Filtre avec .between() — intervalle
commandes_moyennes = df[df["total_amount"].between(100, 500)]
# between(a, b) : inclusif des deux côtés [100, 500]
print(f"Commandes 100€-500€ : {len(commandes_moyennes)}")

# 3.5 Filtre sur texte avec .str.contains()
produits_samsung = df[df["product_name"].str.contains("Samsung", case=False)]
# str.contains("mot") : True si la colonne contient "mot"
# case=False : insensible à la casse (Samsung = samsung = SAMSUNG)
print(f"Produits Samsung : {len(produits_samsung)}")

# 3.6 Filtre avec .query() — syntaxe SQL-like plus lisible
# Les espaces et opérateurs sont directement dans un string
clients_actifs = df.query("total_amount > 200 and region == 'Île-de-France'")
print(f"Query IDF > 200€ : {len(clients_actifs)}")

# Avec variable externe (préfixe @)
seuil = 300
gros_v2 = df.query("total_amount > @seuil")
# @ indique une variable Python externe (pas une colonne du DataFrame)
print(f"Query > {seuil}€ : {len(gros_v2)}")

# 3.7 Filtre sur valeurs non-nulles
df_prix_valides = df[df["price"].notna()]
# notna() = NOT NA = retourne True si la valeur N'EST PAS NaN
# Équivalent à ~df["price"].isna()
print(f"\nLignes avec prix valide : {len(df_prix_valides)} / {len(df)}")

# 3.8 Filtre inversé avec ~
non_retournes = df[~df["is_returned"]]
# ~ = NON logique
# ~df["is_returned"] : True si is_returned est False
print(f"Commandes non retournées : {len(non_retournes)}")


# ═════════════════════════════════════════════════════════════════════════════
# SECTION 4 : CRÉATION DE NOUVELLES COLONNES
# ═════════════════════════════════════════════════════════════════════════════

afficher_separateur("SECTION 4 : FEATURE ENGINEERING")

# On travaille sur une copie propre
df_fe = df.copy()   # fe = Feature Engineering

# 4.1 Calcul arithmétique direct (vectorisé = rapide)
df_fe["revenue_net"] = df_fe["total_amount"] * 0.85
# Hypothèse : marge nette de 85% (15% de coûts)
# Cette opération est vectorisée : Pandas multiplie tous les éléments en C
print(f"\nRevenu net (marge 85%) créé")
print(df_fe[["total_amount", "revenue_net"]].head(3))

# 4.2 Extraction depuis les dates avec .dt accesseur
# .dt permet d'accéder aux composantes d'une colonne datetime
df_fe["mois"]          = df_fe["date"].dt.month        # 1-12
df_fe["mois_nom"]      = df_fe["date"].dt.month_name() # "January", "February"...
df_fe["trimestre"]     = df_fe["date"].dt.quarter      # 1, 2, 3, 4
df_fe["jour_semaine"]  = df_fe["date"].dt.dayofweek    # 0=Lundi, 6=Dimanche
df_fe["nom_jour"]      = df_fe["date"].dt.day_name()   # "Monday", "Tuesday"...
df_fe["annee"]         = df_fe["date"].dt.year         # 2023
df_fe["est_weekend"]   = df_fe["date"].dt.dayofweek >= 5  # True si Sam ou Dim

print("\nColonnes temporelles créées :")
print(df_fe[["date", "mois", "trimestre", "nom_jour", "est_weekend"]].head(5))

# 4.3 np.where — condition ternaire vectorisée
# Syntaxe : np.where(condition, valeur_si_true, valeur_si_false)
df_fe["est_gros_panier"] = np.where(
    df_fe["total_amount"] > 300,  # Condition
    "Gros panier",                 # Si True
    "Panier normal"                # Si False
)
# np.where opère sur tout le tableau en C -> très rapide

# 4.4 pd.cut — discrétiser une variable continue
# Découper customer_age en tranches d'âge
df_fe["tranche_age"] = pd.cut(
    df_fe["customer_age"],                        # Variable à discrétiser
    bins=[17, 25, 35, 50, 65, 100],              # Bornes des intervalles
    labels=["18-25", "26-35", "36-50", "51-65", "65+"],  # Étiquettes
    right=True                                    # Intervalles (a, b] (fermé à droite)
)
# pd.cut crée des intervalles de largeur fixe définie par bins

print("\nDistribution par tranche d'âge :")
print(df_fe["tranche_age"].value_counts().sort_index())

# 4.5 pd.qcut — discrétiser par quantiles (intervalles avec effectifs égaux)
# Utile quand la distribution est asymétrique (comme total_amount)
df_fe["quartile_montant"] = pd.qcut(
    df_fe["total_amount"].fillna(0),  # Remplir les NaN temporairement
    q=4,                               # 4 quantiles = quartiles
    labels=["Q1 (bas)", "Q2", "Q3", "Q4 (élevé)"],
    duplicates="drop"                  # Gérer les ex aequo
)
print("\nDistribution par quartile de montant :")
print(df_fe["quartile_montant"].value_counts().sort_index())

# 4.6 apply — fonction personnalisée sur chaque ligne
# ATTENTION : apply est LENT. Utiliser uniquement quand la vectorisation impossible.
def categoriser_produit(row):
    """
    Catégorise le niveau de prix d'un produit en fonction de sa catégorie.

    Args:
        row : Une ligne du DataFrame (pd.Series)

    Returns:
        str : Le niveau de prix
    """
    # row["category"] : valeur de la colonne "category" pour cette ligne
    # row["price"]    : valeur de la colonne "price" pour cette ligne
    if pd.isna(row["price"]):
        return "Prix inconnu"

    seuils = {
        "Electronique": 300,
        "Vêtements":    80,
        "Maison":       150,
        "Livres":       25
    }
    # .get(key, default) : retourne la valeur ou default si clé absente
    seuil = seuils.get(str(row["category"]), 100)

    return "Premium" if row["price"] > seuil else "Standard"

# axis=1 : appliquer la fonction sur chaque LIGNE (axis=0 = chaque colonne)
df_fe["niveau_prix"] = df_fe.apply(categoriser_produit, axis=1)
print("\nNiveau de prix par catégorie :")
print(df_fe.groupby(["category", "niveau_prix"]).size().unstack(fill_value=0))

# 4.7 map — remplacer des valeurs via un dictionnaire
# Plus rapide qu'apply pour les transformations simples
mapping_region_zone = {
    "Île-de-France": "Nord",
    "Auvergne-RA":   "Centre",
    "PACA":          "Sud",
    "Occitanie":     "Sud",
    "Bretagne":      "Ouest"
}
df_fe["zone"] = df_fe["region"].map(mapping_region_zone)
# map : pour chaque valeur de la colonne, chercher dans le dictionnaire
# Valeurs non trouvées -> NaN
print("\nCorrespondance région -> zone :")
print(df_fe[["region", "zone"]].drop_duplicates().sort_values("region"))


# ═════════════════════════════════════════════════════════════════════════════
# SECTION 5 : TRI ET CLASSEMENT
# ═════════════════════════════════════════════════════════════════════════════

afficher_separateur("SECTION 5 : TRI ET CLASSEMENT")

# 5.1 sort_values — trier par une colonne
top_commandes = df.sort_values("total_amount", ascending=False)
# ascending=False : ordre décroissant (les plus grosses commandes en premier)
print("\nTop 5 commandes :")
print(top_commandes[["product_name", "total_amount", "region"]].head())

# 5.2 Tri multicritère
df_trie = df.sort_values(
    ["region", "total_amount"],      # Trier d'abord par région, puis par montant
    ascending=[True, False]          # Région : croissant ; Montant : décroissant
)
print("\nTop par région (extrait) :")
print(df_trie[["region", "product_name", "total_amount"]].head(8))

# 5.3 nlargest / nsmallest — plus efficace pour les extrêmes
top5_montants = df.nlargest(5, "total_amount")
# nlargest(n, colonne) : les n lignes avec les plus grandes valeurs
# Équivalent à sort_values(ascending=False).head(5) mais plus rapide

bottom5 = df.nsmallest(5, "total_amount")
# nsmallest(n, colonne) : les n plus petites valeurs

print("\nTop 5 montants les plus élevés :")
print(top5_montants[["product_name", "total_amount"]].to_string(index=False))

# 5.4 Classer les lignes avec rank()
df_ranked = df.copy()
df_ranked["rang_montant"] = df_ranked["total_amount"].rank(
    ascending=False,  # Rang 1 = plus grand
    method="dense"    # Pas de rangs sautés en cas d'ex aequo
)
# method options : "average", "min", "max", "first", "dense"
print("\n5 premières lignes avec rang :")
print(df_ranked[["product_name", "total_amount", "rang_montant"]].head())


# ═════════════════════════════════════════════════════════════════════════════
# SECTION 6 : COMPTAGES ET VALEURS UNIQUES
# ═════════════════════════════════════════════════════════════════════════════

afficher_separateur("SECTION 6 : COMPTAGES ET DISTRIBUTION")

# 6.1 value_counts — fréquence de chaque valeur
print("\nDistribution des régions :")
dist_regions = df["region"].value_counts()
# value_counts() : compte les occurrences de chaque valeur unique
# Résultat trié par fréquence décroissante
print(dist_regions)

# Avec proportions (normalize=True)
print("\nProportions des régions :")
print(df["region"].value_counts(normalize=True).round(3))
# normalize=True : divise chaque compte par le total -> proportions [0,1]

# Avec graphique ASCII simple
print("\nDistribution des catégories :")
for cat, count in df["category"].value_counts().items():
    barre = "█" * (count // 200)  # Une barre tous les 200 produits
    print(f"  {str(cat):20s} : {barre} ({count:,})")

# 6.2 nunique — nombre de valeurs uniques
print("\nNombre de valeurs uniques par colonne :")
uniques = df.nunique()
# nunique() : pour chaque colonne, compte les valeurs distinctes (ignore NaN)
print(uniques)

# Identifier les colonnes à haute cardinalité (beaucoup de valeurs uniques)
haute_cardinalite = uniques[uniques > 100]
print(f"\nColonnes haute cardinalité (>100 valeurs uniques) : {haute_cardinalite.index.tolist()}")

# 6.3 Tableau de fréquences professionnel
def tableau_frequences_pro(series, top_n=10):
    """
    Génère un tableau de fréquences avec effectifs et pourcentages.

    Args:
        series (pd.Series) : La colonne à analyser
        top_n (int)        : Nombre de modalités à afficher

    Returns:
        pd.DataFrame : Tableau de fréquences formaté
    """
    vc = series.value_counts()[:top_n]  # Les top_n valeurs les plus fréquentes
    pct = (vc / len(series) * 100).round(2)  # Pourcentages

    # pd.concat combine des Series en colonnes
    tableau = pd.concat([vc, pct], axis=1)
    tableau.columns = ["Effectif", "Pourcentage (%)"]
    tableau.index.name = series.name  # Nommer l'index avec le nom de la colonne

    # Ajouter la ligne Total
    tableau.loc["TOTAL"] = [vc.sum(), pct.sum().round(1)]
    return tableau

print("\nTableau de fréquences — Moyen de paiement :")
print(tableau_frequences_pro(df["payment_method"]))

print("\nTableau de fréquences — Top 5 produits :")
print(tableau_frequences_pro(df["product_name"], top_n=5))


# ═════════════════════════════════════════════════════════════════════════════
# SECTION 7 : MANIPULATION TEMPORELLE
# ═════════════════════════════════════════════════════════════════════════════

afficher_separateur("SECTION 7 : ANALYSE TEMPORELLE")

# S'assurer que "date" est en datetime64
df["date"] = pd.to_datetime(df["date"])
# pd.to_datetime() convertit un string ou objet en datetime64
# Inutile ici si parse_dates=["date"] était dans read_csv, mais bonne habitude

# 7.1 Filtrer sur une plage de dates
debut_Q4 = "2023-10-01"
fin_Q4   = "2023-12-31"

df_q4 = df[(df["date"] >= debut_Q4) & (df["date"] <= fin_Q4)]
# Les comparaisons fonctionnent avec datetime et string ISO 8601 (YYYY-MM-DD)
print(f"\nQ4 2023 : {len(df_q4)} commandes")

# 7.2 Filtrer par mois spécifique
df_novembre = df[df["date"].dt.month == 11]
# .dt.month : accès à la composante "mois" (1-12)
ca_novembre = df_novembre["total_amount"].sum()
print(f"Novembre 2023 : {len(df_novembre)} commandes, CA = {formater_euros(ca_novembre)}")

# 7.3 CA par mois (pour l'analyse de saisonnalité)
ca_mensuel = df.groupby(df["date"].dt.month)["total_amount"].sum()
# groupby : regrouper par mois, puis sum : additionner les total_amount
# Le résultat est une Series avec les mois en index
print("\nCA mensuel (brut) :")
for mois, ca in ca_mensuel.items():
    print(f"  Mois {mois:2d} : {formater_euros(ca)}")

# 7.4 Taux de croissance mensuel
# pct_change() : calcule le changement relatif entre lignes consécutives
croissance = ca_mensuel.pct_change() * 100
# pct_change() = (valeur_actuelle - valeur_precedente) / valeur_precedente * 100
print("\nCroissance mensuelle (%) :")
print(croissance.round(1).dropna())  # dropna() : supprimer le NaN du premier mois


# ═════════════════════════════════════════════════════════════════════════════
# SECTION 8 : EXPORT ET SAUVEGARDE
# ═════════════════════════════════════════════════════════════════════════════

afficher_separateur("SECTION 8 : EXPORT")

# 8.1 Sauvegarder le DataFrame enrichi
df_fe.to_csv("data/sales_enriched.csv", index=False, encoding="utf-8")
# index=False : ne pas sauvegarder l'index Pandas (souvent inutile dans le CSV final)
print("[OK] Dataset enrichi sauvegardé : data/sales_enriched.csv")

# 8.2 Sauvegarder un sous-ensemble (commandes IDF)
df_idf = df[df["region"] == "Île-de-France"].copy()
df_idf.to_csv("data/sales_idf.csv", index=False, encoding="utf-8")
print(f"[OK] Données IDF sauvegardées : {len(df_idf)} lignes")

# 8.3 Information sur les fichiers créés
from pathlib import Path
for fichier in Path("data").glob("*.csv"):
    taille = fichier.stat().st_size / 1024
    print(f"  {fichier.name}: {taille:.1f} KB")


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  ANALYSE ET INTERPRÉTATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

INSIGHTS DÉGAGÉS :

1. DISTRIBUTION RÉGIONALE :
   Île-de-France ≈ 35% des commandes -> marché clé à ne pas négliger
   Les régions PACA et Occitanie ensemble font ≈ 30% -> Sud = 2ème marché

2. MODES DE PAIEMENT :
   Carte bancaire ≈ 55% -> sécuriser les paiements CB est prioritaire
   Chèque ≈ 5% -> risque de retour élevé (à étudier en Partie 7)

3. SAISONNALITÉ (sur données simulées) :
   Le CA est relativement stable sur l'année (distribution aléatoire)
   Sur données réelles, on observerait des pics en novembre/décembre

4. CATÉGORIES :
   Électronique : moins de commandes mais paniers plus élevés
   Livres : beaucoup de commandes, petits paniers
   -> Stratégie : augmenter le cross-selling (livre + accessoire)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8⃣  BONNES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. TOUJOURS COPIER AVANT DE MODIFIER :
   df_clean = df.copy()
   # Ne jamais modifier df directement

2. PRÉFÉRER LES OPÉRATIONS VECTORISÉES À apply() :
   LENT  : df.apply(lambda r: r["price"] * r["quantity"], axis=1)
   RAPIDE: df["price"] * df["quantity"]

3. UTILISER .query() POUR LES FILTRES COMPLEXES :
   Plus lisible que les conditions booléennes imbriquées :
   df.query("region == 'IDF' and total_amount > 200 and not is_returned")

4. NOMMER LES COLONNES EXPLICITEMENT :
   MAUVAIS : df[df.columns[3]]   <- Fragile, dépend de l'ordre
   BON     : df["total_amount"]  <- Explicite, résistant aux réorganisations

5. TOUJOURS VÉRIFIER LES TYPES APRÈS CRÉATION DE COLONNE :
   print(df["nouvelle_colonne"].dtype)
   # S'assurer que c'est int64 ou float64, pas object

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9⃣  ERREURS FRÉQUENTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ERREUR 1 : Utiliser "and"/"or" au lieu de "&"/"|"
  MAUVAIS : df[df["region"] == "IDF" and df["price"] > 100]
  -> ValueError: The truth value of a Series is ambiguous
  BON     : df[(df["region"] == "IDF") & (df["price"] > 100)]

ERREUR 2 : Oublier les parenthèses avec & et |
  MAUVAIS : df[df["price"] > 100 & df["qty"] > 1]
  -> Priorité des opérateurs : & lie plus fort que >
  BON     : df[(df["price"] > 100) & (df["qty"] > 1)]

ERREUR 3 : ChainedIndexing (modification ignorée silencieusement)
  MAUVAIS : df[df["region"] == "IDF"]["new_col"] = 1
  -> SettingWithCopyWarning (modifie une copie, pas l'original)
  BON     : df.loc[df["region"] == "IDF", "new_col"] = 1

ERREUR 4 : .loc vs .iloc confusés sur les slices
  df.loc[0:5]   -> 6 lignes (0, 1, 2, 3, 4, 5) — INCLUSIF
  df.iloc[0:5]  -> 5 lignes (0, 1, 2, 3, 4) — EXCLUSIF

ERREUR 5 : Appeler .dt sur une colonne non-datetime
  df["date"].dt.month  -> AttributeError si "date" est de type object
  Solution : df["date"] = pd.to_datetime(df["date"]) avant d'utiliser .dt

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────────────────

Exercice 2.1 — Sélection
  a) Affichez uniquement les colonnes "product_name", "total_amount" et "region"
  b) Sélectionnez la 100ème commande avec .iloc
  c) Récupérez le total_amount de la ligne d'index 50 avec .loc

Exercice 2.2 — Filtrage simple
  a) Filtrez les commandes de la catégorie "Electronique"
  b) Filtrez les commandes dont le montant est entre 200€ et 800€
  c) Filtrez les commandes retournées (is_returned == True)

Exercice 2.3 — Nouvelles colonnes
  Créez une colonne "tva" = total_amount * 0.20
  Créez une colonne "ht" = total_amount / 1.20 (montant hors taxes)

── NIVEAU INTERMÉDIAIRE ──────────────────────────────────────────────────────

Exercice 2.4 — Filtrage composé
  Trouvez les commandes qui réunissent TOUTES ces conditions :
    - Catégorie Electronique OU Maison
    - Montant > 300€
    - Pas retournées
    - En Île-de-France ou PACA
  Combien de commandes cela représente-t-il ? Quel est leur CA total ?

Exercice 2.5 — Analyse temporelle
  a) Calculez le nombre de commandes par jour de la semaine (lundi->dimanche)
  b) Quel jour génère le plus de CA ?
  c) Créez une colonne "est_weekend" et calculez le panier moyen le week-end
     vs semaine

Exercice 2.6 — Top clients
  Calculez pour chaque client (customer_id) son total_amount cumulé.
  Affichez les 10 clients avec les plus gros achats totaux.
  Indice : df.groupby("customer_id")["total_amount"].sum().nlargest(10)

── NIVEAU AVANCÉ ─────────────────────────────────────────────────────────────

Exercice 2.7 — Segmentation RFM basique
  Créez une colonne "segment_client" basée sur 3 règles :
    - "VIP"      : total_amount > 500 ET dans les 3 mois les plus récents
    - "Fidèle"   : au moins 2 commandes dans l'année
    - "Classique": tous les autres
  Indice : utilisez merge, groupby et np.where combinés

Exercice 2.8 — Rapport régional
  Créez un DataFrame récapitulatif avec pour chaque région :
    - Nombre de commandes
    - CA total
    - Panier moyen
    - Taux de retour (%)
    - Top produit (produit le plus vendu)
  Indice : combiner groupby, agg, et une fonction personnalisée

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉ ULTRA DÉTAILLÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── CORRIGÉ EXERCICE 2.4 — Filtrage composé ──────────────────────────────────

import pandas as pd
import sys
sys.path.insert(0, ".")
from src.data_loader import charger_dataset

df = charger_dataset(verbose=False)

# Condition 1 : catégorie Electronique OU Maison
cond_cat = df["category"].isin(["Electronique", "Maison"])
# .isin() vérifie si la valeur est dans la liste -> booléen par ligne

# Condition 2 : montant > 300€
cond_montant = df["total_amount"] > 300

# Condition 3 : pas retournées
# ~ est l'opérateur NOT en Pandas (inverse les booléens)
cond_retour = ~df["is_returned"]

# Condition 4 : région IDF ou PACA
cond_region = df["region"].isin(["Île-de-France", "PACA"])

# Combiner TOUTES les conditions avec ET (&)
# OBLIGATOIRE : parenthèses autour de chaque condition !
filtre_final = cond_cat & cond_montant & cond_retour & cond_region

# Appliquer le filtre
df_filtre = df[filtre_final]

# Statistiques
nb_commandes = len(df_filtre)
ca_total = df_filtre["total_amount"].sum()
pct_total = nb_commandes / len(df) * 100

print(f"Commandes filtrées : {nb_commandes} ({pct_total:.1f}% du total)")
print(f"CA de ce segment   : {ca_total:,.2f} €")
print(f"Panier moyen       : {ca_total/nb_commandes:.2f} €")

# Vérification : toutes les conditions sont bien respectées
assert df_filtre["category"].isin(["Electronique", "Maison"]).all(), "Erreur catégorie"
assert (df_filtre["total_amount"] > 300).all(), "Erreur montant"
assert (~df_filtre["is_returned"]).all(), "Erreur retour"
assert df_filtre["region"].isin(["Île-de-France", "PACA"]).all(), "Erreur région"
print("[OK] Toutes les assertions passent — filtre correct")


── CORRIGÉ EXERCICE 2.8 — Rapport régional ──────────────────────────────────

import pandas as pd
import sys
sys.path.insert(0, ".")
from src.data_loader import charger_dataset

df = charger_dataset(verbose=False)

# Calculer le top produit par région
def top_produit(groupe):
    """Retourne le nom du produit le plus vendu dans ce groupe."""
    # value_counts() : compter les occurrences de chaque produit
    # .idxmax() : retourner l'index (nom du produit) avec le plus grand count
    return groupe["product_name"].value_counts().idxmax()

# groupby + agg pour calculer toutes les statistiques en une fois
rapport_regional = df.groupby("region").agg(
    nb_commandes    = ("order_id",       "count"),
    ca_total        = ("total_amount",   "sum"),
    panier_moyen    = ("total_amount",   "mean"),
    nb_retours      = ("is_returned",    "sum"),
    # "sum" sur booléen : True=1, False=0 -> compte les True
).round(2)

# Calcul du taux de retour (nb_retours / nb_commandes * 100)
rapport_regional["taux_retour_pct"] = (
    rapport_regional["nb_retours"] / rapport_regional["nb_commandes"] * 100
).round(2)

# Ajouter le top produit via apply sur les groupes
top_par_region = df.groupby("region").apply(top_produit)
# groupby + apply : apply exécute la fonction sur chaque groupe
rapport_regional["top_produit"] = top_par_region

# Trier par CA total décroissant
rapport_regional = rapport_regional.sort_values("ca_total", ascending=False)

print("\n=== RAPPORT RÉGIONAL SHOPMART ===")
print(rapport_regional.to_string())  # to_string() : affiche tout sans troncature

# Export du rapport
rapport_regional.to_csv("reports/rapport_regional.csv", encoding="utf-8")
print("\n[OK] Rapport sauvegardé : reports/rapport_regional.csv")


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[IMPORTANT] RÉSUMÉ DE LA PARTIE 2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Vous avez maîtrisé :
  [OK] Sélection de colonnes : simple, multiple, par type
  [OK] .loc (labels) et .iloc (positions) pour l'accès aux données
  [OK] Filtrage booléen : &, |, ~, .isin(), .between(), .query()
  [OK] Feature engineering : calculs vectorisés, pd.cut, pd.qcut, np.where, apply
  [OK] Manipulation temporelle : .dt.month, .dt.quarter, .dt.day_name()
  [OK] Tri avec sort_values, nlargest, nsmallest
  [OK] Comptages avec value_counts, nunique

PROCHAINE ÉTAPE -> PARTIE 3 : Nettoyage des données
  - Traitement des valeurs manquantes (NaN)
  - Suppression et détection des doublons
  - Détection et traitement des outliers
  - Standardisation des types de données

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 2
================================================================================

================================================================================
  [GRAPHIQUE] DATAINSIGHT PRO — Plateforme Professionnelle d'Analyse de Données
  PARTIE 3 : Nettoyage des Données (Data Cleaning)
================================================================================
  "Les données propres sont la fondation de toute analyse fiable.
   80% du travail d'un data scientist est du nettoyage."
  Niveau : Intermédiaire
  Durée estimée : 5-6 heures
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Après avoir exploré le dataset (Parties 1 & 2), vous avez détecté plusieurs
problèmes qui faussent les analyses :

PROBLÈMES IDENTIFIÉS :
  1. 360 âges clients manquants (3%) -> les statistiques démographiques sont biaisées
  2. 240 prix manquants (2%)         -> le CA calculé est sous-estimé
  3. 50 commandes dupliquées (0.4%)  -> le CA est sur-estimé par double comptage
  4. 30 commandes avec montants > 5000€ (outliers) -> faussent les moyennes
  5. Colonnes "date" parfois mal typées lors d'imports Excel -> analyses impossibles

Marc Dumont : "Ces données sales me donnent de faux KPIs. Avant d'aller plus loin,
               nettoyez tout ça proprement, avec un audit complet."

VOTRE MISSION :
  -> Auditer les données : diagnostiquer TOUS les problèmes
  -> Nettoyer méthodiquement : NaN, doublons, outliers, types
  -> Documenter les choix : POURQUOI vous avez imputé plutôt que supprimé
  -> Valider : vérifier que le nettoyage n'a pas introduit de biais

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  [OK] Comprendre les 3 mécanismes de manquance : MCAR, MAR, MNAR
  [OK] Détecter et auditer les NaN avec isnull(), info(), heatmap
  [OK] Imputer les NaN : médiane, mode, imputation par groupe
  [OK] Détecter et supprimer les doublons avec duplicated() et drop_duplicates()
  [OK] Détecter les outliers par Z-score et IQR
  [OK] Traiter les outliers : suppression, winsorization, log transform
  [OK] Corriger les types de données
  [OK] Écrire le module data_cleaning.py professionnel
  [OK] Valider le nettoyage avec des assertions

CONCEPTS DU GUIDE UTILISÉS :
  - Chapitre 22 : Données manquantes (MCAR, MAR, MNAR, fillna, KNN)
  - Chapitre 23 : Doublons (duplicated, drop_duplicates, fuzzy matching)
  - Chapitre 24 : Outliers (Z-score, IQR, Isolation Forest)
  - Chapitre 25 : Normalisation (MinMaxScaler, StandardScaler, RobustScaler)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  THÉORIE APPLIQUÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LES 3 MÉCANISMES DE MANQUANCE (Rubin, 1976) :

MCAR (Missing Completely At Random) :
  La donnée manque au hasard total, sans lien avec les autres données.
  Exemple ShopSmart : le serveur a planté pendant 30 minutes -> quelques
  prix non enregistrés aléatoirement.
  -> On peut imputer ou supprimer sans biais.

MAR (Missing At Random, conditionnel) :
  La donnée manque selon d'autres variables OBSERVÉES.
  Exemple ShopSmart : les clients âgés (>60 ans) remplissent moins le
  champ "âge" (variable observée = is_returned).
  -> Imputer en tenant compte de la variable explicative.

MNAR (Missing Not At Random) :
  La donnée manque à cause de sa propre valeur.
  Exemple ShopSmart : les prix très élevés ne sont pas renseignés
  (clients VIP avec tarification spéciale).
  -> Cas le plus difficile. Créer un indicateur de manquance.

RÈGLE DE DÉCISION :
  < 5% NaN    : n'importe quelle méthode fonctionne
  5-20% NaN   : imputation recommandée (médiane, mode, KNN)
  > 20% NaN   : imputation avancée + indicateur de manquance
  > 50% NaN   : envisager de supprimer la colonne

MÉTHODES D'IMPUTATION :
  Médiane      : robuste aux outliers -> pour variables numériques asymétriques
  Moyenne      : uniquement si distribution symétrique sans outliers
  Mode         : pour les variables catégorielles
  Par groupe   : imputer avec la médiane du sous-groupe -> plus précis
  KNN          : utilise les voisins similaires -> plus sophistiqué

OUTLIERS — QUAND SUPPRIMER, QUAND GARDER ?
  Erreur de saisie (prix = 999999€ pour un t-shirt) -> CORRIGER ou SUPPRIMER
  Valeur réelle mais extrême (commande B2B de 10 000€) -> GARDER, analyser séparément
  Règle : demander l'avis du métier avant de supprimer !

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  IMPLÉMENTATION COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

══════════════════════════════════════════════════════════════════
FICHIER : src/data_cleaning.py — Module complet de nettoyage
══════════════════════════════════════════════════════════════════

"""
data_cleaning.py

Responsabilité : Nettoyer et préparer les données pour l'analyse.
Ce module contient toutes les fonctions de nettoyage du dataset ShopSmart.

Pipeline de nettoyage appliqué :
  1. Audit initial (diagnostic des problèmes)
  2. Correction des types
  3. Suppression des doublons
  4. Traitement des valeurs manquantes
  5. Traitement des outliers
  6. Validation finale
"""

import pandas as pd          # Manipulation de données
import numpy as np           # Calcul numérique
from scipy import stats      # Tests statistiques (Z-score)
import warnings              # Supprimer les avertissements non critiques

# Import depuis nos modules locaux
from .utils import afficher_separateur, mesurer_temps

# Supprimer les avertissements pandas de SettingWithCopyWarning
# (on gère nous-mêmes les copies avec .copy())
warnings.filterwarnings("ignore", category=pd.errors.PerformanceWarning)


# ─────────────────────────────────────────────────────────────────────────────
# PARTIE 1 : AUDIT
# ─────────────────────────────────────────────────────────────────────────────

def auditer_dataset(df):
    """
    Effectue un audit complet du dataset et retourne un rapport.

    Analyse :
      - Dimensions et mémoire
      - Types de données
      - Valeurs manquantes (nombre et %)
      - Doublons
      - Plages de valeurs (min/max pour les numériques)

    Args:
        df (pd.DataFrame) : Le DataFrame brut à auditer

    Returns:
        dict : Dictionnaire avec tous les résultats de l'audit
    """
    afficher_separateur("AUDIT DU DATASET")

    rapport = {}  # Dictionnaire qui stockera tous les résultats

    # 1. Dimensions
    rapport["n_lignes"]   = df.shape[0]
    rapport["n_colonnes"] = df.shape[1]
    memoire_mb = df.memory_usage(deep=True).sum() / 1024 / 1024
    rapport["memoire_mb"] = round(memoire_mb, 2)

    print(f"\n1. DIMENSIONS")
    print(f"   {rapport['n_lignes']:,} lignes × {rapport['n_colonnes']} colonnes")
    print(f"   Mémoire : {rapport['memoire_mb']} MB")

    # 2. Types de données
    print(f"\n2. TYPES DE DONNÉES")
    types_count = df.dtypes.value_counts()
    for dtype, count in types_count.items():
        print(f"   {str(dtype):15s} : {count} colonne(s)")

    # 3. Valeurs manquantes
    print(f"\n3. VALEURS MANQUANTES")
    nan_counts = df.isnull().sum()
    nan_pcts   = (nan_counts / len(df) * 100).round(2)

    rapport_nan = pd.DataFrame({
        "n_manquants": nan_counts,
        "pct_manquants": nan_pcts
    }).sort_values("n_manquants", ascending=False)

    rapport["nan_par_colonne"] = rapport_nan
    rapport["total_nan"] = nan_counts.sum()

    cols_avec_nan = nan_counts[nan_counts > 0]
    if len(cols_avec_nan) == 0:
        print("   [OK] Aucune valeur manquante")
    else:
        for col in cols_avec_nan.index:
            n = nan_counts[col]
            pct = nan_pcts[col]
            niveau = "[ROUGE] CRITIQUE" if pct > 20 else ("[JAUNE] ATTENTION" if pct > 5 else "[VERT] OK")
            print(f"   {col:20s} : {n:5d} NaN ({pct:.1f}%) {niveau}")

    # 4. Doublons
    n_doublons = df.duplicated().sum()
    rapport["n_doublons"] = n_doublons
    pct_doublons = n_doublons / len(df) * 100

    print(f"\n4. DOUBLONS")
    print(f"   {n_doublons} doublons ({pct_doublons:.2f}%)")

    # 5. Statistiques des numériques (pour détecter les outliers potentiels)
    print(f"\n5. STATISTIQUES (colonnes numériques)")
    cols_num = df.select_dtypes(include=[np.number]).columns
    for col in cols_num:
        serie = df[col].dropna()
        q1, q3 = serie.quantile(0.25), serie.quantile(0.75)
        iqr = q3 - q1
        borne_inf = q1 - 1.5 * iqr
        borne_sup = q3 + 1.5 * iqr
        n_outliers_iqr = ((serie < borne_inf) | (serie > borne_sup)).sum()

        print(f"   {col:20s} : min={serie.min():.1f}, max={serie.max():.1f}, "
              f"outliers IQR={n_outliers_iqr}")

    # 6. Cardinalité des catégorielles
    print(f"\n6. COLONNES CATÉGORIELLES — CARDINALITÉ")
    cols_cat = df.select_dtypes(include=["object", "category"]).columns
    for col in cols_cat:
        n_uniques = df[col].nunique()
        top_val = df[col].value_counts().iloc[0]
        top_name = df[col].value_counts().index[0]
        print(f"   {col:20s} : {n_uniques} valeurs uniques, top = '{top_name}' ({top_val}×)")

    afficher_separateur("FIN DE L'AUDIT")
    return rapport


# ─────────────────────────────────────────────────────────────────────────────
# PARTIE 2 : CORRECTION DES TYPES
# ─────────────────────────────────────────────────────────────────────────────

def corriger_types(df):
    """
    Corrige les types de données pour garantir la cohérence.

    Transformations appliquées :
      - "date" -> datetime64
      - "category", "region", "payment_method" -> category
      - "is_returned" -> bool
      - "quantity" -> int64

    Args:
        df (pd.DataFrame) : DataFrame à corriger (sera modifié sur une copie)

    Returns:
        pd.DataFrame : DataFrame avec les types corrigés
    """
    df = df.copy()  # TOUJOURS travailler sur une copie !

    print("\n--- Correction des types ---")
    avant = df.dtypes.copy()  # Sauvegarder les types avant modification

    # 1. Colonne date -> datetime64
    if df["date"].dtype == "object":
        # Si "date" est de type object, c'est un string -> convertir
        df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d", errors="coerce")
        # errors="coerce" : si une date est invalide, la mettre à NaT (Not a Time)
        # au lieu de lever une erreur
        print("  [OK] 'date' converti en datetime64")
    else:
        # Même si pas de type object, s'assurer que c'est bien datetime
        df["date"] = pd.to_datetime(df["date"], errors="coerce")

    # 2. Colonnes catégorielles -> type "category" (économie mémoire ~70%)
    for col in ["category", "region", "payment_method"]:
        if col in df.columns and df[col].dtype != "category":
            df[col] = df[col].astype("category")
            print(f"  [OK] '{col}' converti en category")

    # 3. is_returned -> bool (si ce n'est pas déjà le cas)
    if df["is_returned"].dtype != bool:
        # Convertir les variantes textuelles possibles
        df["is_returned"] = df["is_returned"].map({
            True: True, False: False,
            "True": True, "False": False,
            "1": True, "0": False,
            1: True, 0: False
        })
        df["is_returned"] = df["is_returned"].astype(bool)
        print("  [OK] 'is_returned' converti en bool")

    # 4. quantity -> int64 (doit être un entier)
    if "quantity" in df.columns:
        df["quantity"] = df["quantity"].fillna(1).astype("int64")
        # fillna(1) : si une quantité est NaN, on suppose 1 (valeur par défaut)

    # 5. Rapport de changements
    apres = df.dtypes
    changements = [(col, str(avant[col]), str(apres[col]))
                   for col in df.columns
                   if str(avant[col]) != str(apres[col])]
    if changements:
        print(f"\n  {len(changements)} types corrigés :")
        for col, avant_type, apres_type in changements:
            print(f"    {col}: {avant_type} -> {apres_type}")

    return df


# ─────────────────────────────────────────────────────────────────────────────
# PARTIE 3 : SUPPRESSION DES DOUBLONS
# ─────────────────────────────────────────────────────────────────────────────

def supprimer_doublons(df, cle_unique="order_id"):
    """
    Détecte et supprime les lignes dupliquées.

    Stratégie :
      1. Doublons EXACTS (toutes colonnes identiques) -> supprimer
      2. Doublons sur la clé métier (order_id) -> supprimer (garder la 1ère)

    Args:
        df (pd.DataFrame) : DataFrame source
        cle_unique (str)  : Colonne clé métier (doit être unique)

    Returns:
        pd.DataFrame : DataFrame sans doublons
    """
    df = df.copy()
    n_initial = len(df)

    print(f"\n--- Suppression des doublons (n initial : {n_initial:,}) ---")

    # Étape 1 : Doublons exacts (TOUTES les colonnes identiques)
    # duplicated() retourne True pour chaque ligne qui est une copie exacte
    # d'une ligne précédente
    masque_exacts = df.duplicated()
    n_exacts = masque_exacts.sum()
    print(f"  Doublons exacts trouvés : {n_exacts}")

    if n_exacts > 0:
        # drop_duplicates() supprime les doublons et retourne un nouveau DataFrame
        # keep="first" : garder la première occurrence, supprimer les suivantes
        df = df.drop_duplicates(keep="first")
        print(f"  -> {n_exacts} doublons exacts supprimés")

    # Étape 2 : Doublons sur la clé métier (order_id doit être unique !)
    masque_cle = df.duplicated(subset=[cle_unique])
    n_cle = masque_cle.sum()
    print(f"  Doublons sur '{cle_unique}' : {n_cle}")

    if n_cle > 0:
        df = df.drop_duplicates(subset=[cle_unique], keep="first")
        print(f"  -> {n_cle} doublons de '{cle_unique}' supprimés")

    # Étape 3 : Réinitialiser l'index (car les indices peuvent avoir des trous)
    df = df.reset_index(drop=True)
    # reset_index(drop=True) : recréer un index 0, 1, 2, ... sans conserver l'ancien
    # drop=True : ne pas conserver l'ancien index comme colonne

    n_final = len(df)
    n_supprime = n_initial - n_final
    print(f"\n  Résultat : {n_initial:,} -> {n_final:,} lignes ({n_supprime} supprimées)")
    print(f"  Intégrité : order_id unique = {df[cle_unique].nunique() == len(df)}")

    return df


# ─────────────────────────────────────────────────────────────────────────────
# PARTIE 4 : TRAITEMENT DES VALEURS MANQUANTES
# ─────────────────────────────────────────────────────────────────────────────

def traiter_nan(df):
    """
    Traite toutes les valeurs manquantes du dataset ShopSmart.

    Stratégie par colonne :
      - customer_age (3% NaN, MCAR) -> imputation par médiane du groupe d'âge
      - price (2% NaN, MAR) -> imputation par médiane du produit
      - Autres : pas de NaN attendus

    Choix justifiés :
      - Médiane plutôt que moyenne : la distribution des prix est asymétrique
        (outliers vers le haut). La médiane est robuste aux valeurs extrêmes.
      - Par groupe plutôt que globale : le prix d'un Laptop ≠ prix d'un T-shirt.
        Imputer avec la médiane de chaque produit est plus précis.

    Args:
        df (pd.DataFrame) : DataFrame avec NaN

    Returns:
        pd.DataFrame : DataFrame sans NaN
    """
    df = df.copy()

    print("\n--- Traitement des valeurs manquantes ---")

    # ─── customer_age ───────────────────────────────────────────────────────
    n_nan_age = df["customer_age"].isnull().sum()
    if n_nan_age > 0:
        print(f"\n  customer_age : {n_nan_age} NaN -> imputation par médiane")

        # Mécanisme supposé : MCAR (les NaN sont aléatoires)
        # Stratégie : médiane globale (car MCAR)
        mediane_age = df["customer_age"].median()
        # .median() ignore automatiquement les NaN pour calculer la médiane

        df["customer_age"] = df["customer_age"].fillna(mediane_age)
        # .fillna(valeur) : remplacer NaN par la valeur spécifiée

        print(f"    Médiane utilisée : {mediane_age:.1f} ans")
        print(f"    NaN restants : {df['customer_age'].isnull().sum()}")

    # ─── price ──────────────────────────────────────────────────────────────
    n_nan_price = df["price"].isnull().sum()
    if n_nan_price > 0:
        print(f"\n  price : {n_nan_price} NaN -> imputation par médiane du produit")

        # Mécanisme supposé : MAR (certains produits ont plus de prix manquants)
        # Stratégie : médiane de chaque produit (imputation par groupe)

        # groupby("product_name")["price"] : groupe les prix par produit
        # .transform("median") : remplace chaque NaN par la médiane de son groupe
        # Avantage : le Laptop garde une médiane de ~800€, pas la médiane globale
        mediane_par_produit = df.groupby("product_name")["price"].transform("median")
        df["price"] = df["price"].fillna(mediane_par_produit)

        # Vérification : au cas où un produit entier n'a que des NaN
        # (peu probable, mais on sécurise avec la médiane globale)
        mediane_globale = df["price"].median()
        df["price"] = df["price"].fillna(mediane_globale)

        print(f"    NaN restants : {df['price'].isnull().sum()}")

    # ─── total_amount : recalculer depuis price × quantity ────────────────
    # Si price était NaN et vient d'être imputé, total_amount peut être incorrect
    n_nan_total = df["total_amount"].isnull().sum()
    if n_nan_total > 0:
        print(f"\n  total_amount : {n_nan_total} NaN -> recalcul price × quantity")
        df["total_amount"] = df["price"] * df["quantity"]

    # ─── Rapport final ─────────────────────────────────────────────────────
    nan_restants = df.isnull().sum().sum()
    print(f"\n  [OK] NaN restants après traitement : {nan_restants}")

    return df


def traiter_nan_avance(df):
    """
    Version avancée : imputation par groupe ET création d'indicateurs.

    Pour les cas MNAR (price manquant pour les commandes VIP), on crée
    un indicateur binaire "price_was_missing" avant d'imputer.

    Args:
        df (pd.DataFrame) : DataFrame avec NaN

    Returns:
        pd.DataFrame : DataFrame enrichi et sans NaN
    """
    df = df.copy()

    # Créer l'indicateur de manquance AVANT d'imputer
    # C'est utile en ML : le modèle peut apprendre que "price manquant -> commande VIP"
    for col in ["price", "customer_age"]:
        indicateur = f"{col}_imputed"
        df[indicateur] = df[col].isnull().astype(int)
        # .isnull() : True si NaN, False sinon
        # .astype(int) : True->1, False->0
        n_flagged = df[indicateur].sum()
        if n_flagged > 0:
            print(f"  Indicateur '{indicateur}' créé : {n_flagged} valeurs marquées")

    # Puis imputer normalement
    df = traiter_nan(df)

    return df


# ─────────────────────────────────────────────────────────────────────────────
# PARTIE 5 : TRAITEMENT DES OUTLIERS
# ─────────────────────────────────────────────────────────────────────────────

def detecter_outliers_iqr(serie, facteur=1.5):
    """
    Détecte les outliers avec la méthode IQR (Tukey, 1977).

    Méthode : Q1 - facteur*IQR  et  Q3 + facteur*IQR
    facteur=1.5 -> outliers classiques
    facteur=3.0 -> outliers extrêmes

    Args:
        serie  (pd.Series) : La colonne numérique à analyser
        facteur (float)    : Multiplicateur de l'IQR (1.5 par défaut)

    Returns:
        tuple : (masque_outliers, borne_inf, borne_sup)
                masque_outliers : Series booléenne (True = outlier)
    """
    # Calculer les quartiles en ignorant les NaN
    q1 = serie.quantile(0.25)  # 1er quartile : 25% des données sont en dessous
    q3 = serie.quantile(0.75)  # 3ème quartile : 75% des données sont en dessous
    iqr = q3 - q1              # IQR = Interquartile Range = Q3 - Q1

    # Bornes de Tukey
    borne_inf = q1 - facteur * iqr  # En dessous = outlier bas
    borne_sup = q3 + facteur * iqr  # Au-dessus = outlier haut

    # Créer le masque booléen : True si la valeur est un outlier
    masque = (serie < borne_inf) | (serie > borne_sup)

    return masque, borne_inf, borne_sup


def detecter_outliers_zscore(serie, seuil=3):
    """
    Détecte les outliers avec la méthode du Z-score.

    Z-score = (valeur - moyenne) / écart-type
    Valeur > seuil (typiquement 3) écarts-types -> outlier

    Attention : sensible aux outliers eux-mêmes (la moyenne est influencée
    par les outliers). Préférer IQR si beaucoup d'outliers.

    Args:
        serie (pd.Series) : La colonne à analyser
        seuil (float)     : Seuil de Z-score (3.0 par défaut)

    Returns:
        pd.Series : Masque booléen (True = outlier)
    """
    # zscore() de scipy.stats calcule le Z-score de chaque élément
    # nan_policy="omit" : ignorer les NaN dans le calcul
    z_scores = np.abs(stats.zscore(serie.dropna()))
    # np.abs() : valeur absolue (on ne distingue pas les outliers hauts et bas)

    # Créer un masque de même longueur que serie (incluant les NaN)
    masque = pd.Series(False, index=serie.index)
    masque[serie.dropna().index] = z_scores > seuil
    # On met True seulement pour les indices avec des données non-NaN

    return masque


def traiter_outliers(df, methode="iqr", action="winsorize"):
    """
    Détecte et traite les outliers dans les colonnes numériques clés.

    Colonnes traitées :
      - total_amount : les 30 commandes B2B avec montants > 5000€
      - price        : quelques prix aberrants (erreurs de saisie)

    Actions disponibles :
      "winsorize" : Remplacer par les bornes (borne_inf ou borne_sup)
      "supprimer" : Supprimer les lignes avec outliers
      "flag"      : Marquer mais garder (créer une colonne indicateur)

    Args:
        df     (pd.DataFrame) : DataFrame à traiter
        methode (str)         : "iqr" ou "zscore"
        action  (str)         : "winsorize", "supprimer", ou "flag"

    Returns:
        pd.DataFrame : DataFrame avec outliers traités
    """
    df = df.copy()

    print(f"\n--- Traitement des outliers (méthode: {methode}, action: {action}) ---")

    colonnes_a_traiter = {
        "total_amount": {"facteur_iqr": 3.0, "seuil_zscore": 3.5},
        "price":        {"facteur_iqr": 3.0, "seuil_zscore": 3.5},
    }

    for col, params in colonnes_a_traiter.items():
        if col not in df.columns:
            continue

        serie = df[col]

        # Détecter avec la méthode choisie
        if methode == "iqr":
            masque, borne_inf, borne_sup = detecter_outliers_iqr(
                serie, facteur=params["facteur_iqr"])
        else:  # zscore
            masque = detecter_outliers_zscore(serie, seuil=params["seuil_zscore"])
            borne_inf = serie.mean() - params["seuil_zscore"] * serie.std()
            borne_sup = serie.mean() + params["seuil_zscore"] * serie.std()

        n_outliers = masque.sum()
        pct_outliers = n_outliers / len(df) * 100

        print(f"\n  {col} : {n_outliers} outliers ({pct_outliers:.2f}%)")
        print(f"    Borne inf : {borne_inf:.2f}")
        print(f"    Borne sup : {borne_sup:.2f}")
        print(f"    Min actuel: {serie.min():.2f}, Max actuel: {serie.max():.2f}")

        if n_outliers == 0:
            print("    -> Aucun outlier à traiter")
            continue

        # Appliquer l'action choisie
        if action == "winsorize":
            # Winsorization : remplacer les valeurs extrêmes par les bornes
            # .clip(lower, upper) : limite les valeurs entre lower et upper
            df[col] = df[col].clip(lower=borne_inf, upper=borne_sup)
            print(f"    -> Winsorisation appliquée : valeurs clippées à [{borne_inf:.2f}, {borne_sup:.2f}]")

        elif action == "supprimer":
            # Supprimer les lignes avec outliers
            df = df[~masque].copy()
            # ~masque : inverser le masque (garder les NON-outliers)
            df = df.reset_index(drop=True)  # Réinitialiser l'index
            print(f"    -> {n_outliers} lignes supprimées")

        elif action == "flag":
            # Créer un indicateur sans supprimer
            df[f"{col}_outlier"] = masque.astype(int)
            print(f"    -> Colonne '{col}_outlier' créée")

    # Recalculer total_amount si price a été modifié
    if action == "winsorize":
        df["total_amount"] = (df["price"] * df["quantity"]).round(2)

    return df


def appliquer_log_transform(df, colonnes=None):
    """
    Applique une transformation logarithmique pour réduire l'asymétrie.

    Utile pour les variables très asymétriques (distributions log-normales)
    comme les prix et les montants en e-commerce.

    log1p(x) = log(1 + x) -> évite log(0) si des zéros sont présents.

    Args:
        df       (pd.DataFrame) : DataFrame source
        colonnes (list)         : Colonnes à transformer (None = auto-détection)

    Returns:
        pd.DataFrame : DataFrame avec les nouvelles colonnes log_*
    """
    df = df.copy()

    if colonnes is None:
        # Auto-détecter les colonnes très asymétriques (skew > 1)
        cols_num = df.select_dtypes(include=[np.number]).columns
        colonnes = [col for col in cols_num
                    if df[col].skew() > 1 and df[col].min() >= 0]

    for col in colonnes:
        col_log = f"log_{col}"
        # np.log1p(x) = log(x + 1)
        # Avantage : log1p(0) = 0 (pas d'erreur sur les zéros)
        df[col_log] = np.log1p(df[col])

        skew_avant = df[col].skew()
        skew_apres = df[col_log].skew()
        print(f"  {col} -> {col_log} : skew {skew_avant:.2f} -> {skew_apres:.2f}")

    return df


# ─────────────────────────────────────────────────────────────────────────────
# PARTIE 6 : VALIDATION DU NETTOYAGE
# ─────────────────────────────────────────────────────────────────────────────

def valider_dataset_propre(df):
    """
    Valide que le dataset nettoyé respecte les règles de qualité.

    Effectue une série d'assertions (tests automatisés) qui lèvent
    une AssertionError si une règle est violée.

    Les assertions documentent les INVARIANTS du dataset :
    des propriétés qui doivent TOUJOURS être vraies.

    Args:
        df (pd.DataFrame) : Le DataFrame nettoyé

    Returns:
        bool : True si toutes les validations passent

    Raises:
        AssertionError : Si une règle de qualité est violée
    """
    erreurs = []  # Liste des erreurs trouvées

    print("\n--- Validation du dataset nettoyé ---")

    # Règle 1 : Pas de NaN dans les colonnes critiques
    for col in ["order_id", "customer_id", "price", "quantity",
                "total_amount", "date", "region", "payment_method"]:
        n_nan = df[col].isnull().sum()
        if n_nan > 0:
            erreurs.append(f"'{col}' : {n_nan} NaN restants")

    # Règle 2 : order_id est unique
    n_doublons_id = df["order_id"].duplicated().sum()
    if n_doublons_id > 0:
        erreurs.append(f"'order_id' : {n_doublons_id} doublons")

    # Règle 3 : Valeurs dans les plages attendues
    if (df["price"] < 0).any():
        erreurs.append("'price' contient des valeurs négatives")

    if (df["quantity"] < 1).any():
        erreurs.append("'quantity' contient des valeurs < 1")

    if (df["customer_age"].dropna() < 18).any():
        erreurs.append("'customer_age' contient des valeurs < 18 (mineurs)")

    if (df["customer_age"].dropna() > 120).any():
        erreurs.append("'customer_age' contient des valeurs > 120")

    # Règle 4 : total_amount est cohérent avec price × quantity
    total_recalcule = (df["price"] * df["quantity"]).round(2)
    incoherences = ((df["total_amount"] - total_recalcule).abs() > 0.01).sum()
    if incoherences > 0:
        erreurs.append(f"total_amount incohérent avec price×quantity : {incoherences} lignes")

    # Règle 5 : Valeurs catégorielles connues
    regions_valides = {"Île-de-France", "Auvergne-RA", "PACA", "Occitanie", "Bretagne"}
    regions_inconnues = set(df["region"].unique()) - regions_valides
    if regions_inconnues:
        erreurs.append(f"Régions inconnues : {regions_inconnues}")

    paiements_valides = {"Carte", "PayPal", "Virement", "Chèque"}
    paiements_inconnus = set(df["payment_method"].unique()) - paiements_valides
    if paiements_inconnus:
        erreurs.append(f"Paiements inconnus : {paiements_inconnus}")

    # Règle 6 : Pas de doublons
    n_doublons = df.duplicated().sum()
    if n_doublons > 0:
        erreurs.append(f"{n_doublons} lignes dupliquées restantes")

    # Règle 7 : Dates dans la plage attendue (2023)
    dates_hors_plage = df[(df["date"].dt.year < 2023) | (df["date"].dt.year > 2023)]
    if len(dates_hors_plage) > 0:
        erreurs.append(f"{len(dates_hors_plage)} dates hors de 2023")

    # Rapport final
    if erreurs:
        print("  [X] VALIDATION ÉCHOUÉE — Erreurs trouvées :")
        for erreur in erreurs:
            print(f"     • {erreur}")
        return False
    else:
        print("  [OK] TOUTES LES VALIDATIONS PASSENT — Dataset prêt")
        print(f"     {len(df):,} lignes × {len(df.columns)} colonnes")
        print(f"     Mémoire : {df.memory_usage(deep=True).sum() / 1024:.1f} KB")
        return True


# ─────────────────────────────────────────────────────────────────────────────
# PIPELINE COMPLET DE NETTOYAGE
# ─────────────────────────────────────────────────────────────────────────────

@mesurer_temps
def pipeline_nettoyage(df, verbose=True):
    """
    Exécute le pipeline complet de nettoyage dans l'ordre correct :
      1. Audit initial
      2. Correction des types
      3. Suppression des doublons
      4. Traitement des NaN
      5. Traitement des outliers
      6. Validation finale

    Args:
        df      (pd.DataFrame) : Dataset brut
        verbose (bool)         : Afficher les détails

    Returns:
        pd.DataFrame : Dataset nettoyé et validé
    """
    afficher_separateur("PIPELINE DE NETTOYAGE SHOPMART")

    print(f"\nDataset brut : {df.shape[0]:,} lignes × {df.shape[1]} colonnes")

    # Étape 0 : Audit
    if verbose:
        rapport_audit = auditer_dataset(df)

    # Étape 1 : Types
    afficher_separateur("ÉTAPE 1/4 : Types de données")
    df_clean = corriger_types(df)

    # Étape 2 : Doublons
    afficher_separateur("ÉTAPE 2/4 : Doublons")
    df_clean = supprimer_doublons(df_clean)

    # Étape 3 : NaN
    afficher_separateur("ÉTAPE 3/4 : Valeurs manquantes")
    df_clean = traiter_nan(df_clean)

    # Étape 4 : Outliers (winsorization pour garder toutes les commandes)
    afficher_separateur("ÉTAPE 4/4 : Outliers")
    df_clean = traiter_outliers(df_clean, methode="iqr", action="winsorize")

    # Étape 5 : Validation
    afficher_separateur("VALIDATION FINALE")
    est_valide = valider_dataset_propre(df_clean)

    if est_valide:
        afficher_separateur("NETTOYAGE TERMINÉ [OK]")
        print(f"\nDataset propre : {df_clean.shape[0]:,} lignes × {df_clean.shape[1]} colonnes")
        print(f"Lignes perdues : {df.shape[0] - df_clean.shape[0]} "
              f"({(df.shape[0]-df_clean.shape[0])/df.shape[0]*100:.2f}%)")
    else:
        print("[ATTENTION] Certaines validations ont échoué. Vérifiez les erreurs ci-dessus.")

    return df_clean


# ─────────────────────────────────────────────────────────────────────────────
# TEST DIRECT
# ─────────────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import sys
    sys.path.insert(0, ".")
    from src.data_loader import charger_dataset

    # Charger les données brutes
    df_brut = charger_dataset("data/sales_data.csv", verbose=False)

    # Nettoyer
    df_propre = pipeline_nettoyage(df_brut)

    # Sauvegarder le dataset propre
    df_propre.to_csv("data/sales_clean.csv", index=False, encoding="utf-8")
    print("\n[OK] Dataset propre sauvegardé : data/sales_clean.csv")


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  ANALYSE ET INTERPRÉTATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

AVANT NETTOYAGE :
  12 050 lignes (avec doublons)
  360 NaN dans customer_age (3%)
  240 NaN dans price (2%)
  30 outliers dans total_amount (>5 000€)

APRÈS NETTOYAGE :
  ~12 000 lignes (50 doublons supprimés)
  0 NaN dans toutes les colonnes
  Outliers de total_amount clippés à ~2 500€ (borne IQR ×3)

IMPACT BUSINESS :
  -> Le CA calculé était SOUS-ESTIMÉ de ~2% (240 prix manquants)
  -> Et SURESTIMÉ de ~0.4% (doublons comptés deux fois)
  -> Après nettoyage : CA plus précis et fiable pour les décisions

DÉCISION DOCUMENTÉE SUR LES OUTLIERS :
  Les 30 commandes avec total_amount > 5 000€ sont des commandes B2B
  (entreprises commandant en gros). Choix : winsorisation (clipper à la borne
  supérieure IQR×3) plutôt que suppression, pour ne pas perdre ces clients.
  -> Ces clients B2B méritent une analyse dédiée en Partie 7.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────────────────

Exercice 3.1 — Audit manuel
  Sans utiliser auditer_dataset(), écrivez vous-même le code pour afficher :
  a) Le nombre de NaN par colonne
  b) Le pourcentage de doublons
  c) Les valeurs min/max de "total_amount"

Exercice 3.2 — Suppression simple
  a) Comptez les doublons avec df.duplicated().sum()
  b) Supprimez-les avec df.drop_duplicates()
  c) Vérifiez qu'il n'y en a plus

Exercice 3.3 — Imputation basique
  Imputez customer_age avec la MOYENNE (pas la médiane).
  Comparez le résultat avec l'imputation par médiane.
  Laquelle est plus représentative ? Pourquoi ?

── NIVEAU INTERMÉDIAIRE ──────────────────────────────────────────────────────

Exercice 3.4 — Détection visuelle
  Tracez un boxplot de total_amount avec matplotlib.
  Identifiez visuellement les outliers.
  Calculez ensuite avec IQR pour confirmer.

Exercice 3.5 — Imputation par groupe
  Imputez "price" par la médiane de chaque CATÉGORIE (pas par produit).
  Comparez les résultats : imputation par produit vs par catégorie.
  Laquelle est plus précise ? Pourquoi ?

Exercice 3.6 — Winsorisation vs suppression
  Calculez le CA total avec :
    a) Les outliers supprimés
    b) Les outliers winsorizés
    c) Les outliers gardés
  Quelle différence y a-t-il entre les 3 ? Quelle approche recommandez-vous ?

── NIVEAU AVANCÉ ─────────────────────────────────────────────────────────────

Exercice 3.7 — Tests de mécanisme
  Vérifiez si les NaN dans customer_age sont liés au mode de paiement
  (test du chi-carré entre "age_manquant" et "payment_method").
  Qu'est-ce que cela révèle sur le mécanisme (MCAR, MAR, ou MNAR) ?

Exercice 3.8 — KNN Imputation
  Installez sklearn (pip install scikit-learn) et utilisez KNNImputer
  pour imputer customer_age en tenant compte de total_amount et quantity.
  Comparez la distribution avant/après avec un histogramme.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉ ULTRA DÉTAILLÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── CORRIGÉ EXERCICE 3.5 — Imputation par groupe ─────────────────────────────

import pandas as pd
import numpy as np
import sys
sys.path.insert(0, ".")
from src.data_loader import charger_dataset

df = charger_dataset(verbose=False)
df_test = df.copy()

# Méthode 1 : imputation par PRODUIT (plus précise)
mediane_par_produit = df_test.groupby("product_name")["price"].transform("median")
df_par_produit = df_test.copy()
df_par_produit["price"] = df_par_produit["price"].fillna(mediane_par_produit)

# Méthode 2 : imputation par CATÉGORIE (moins précise)
mediane_par_cat = df_test.groupby("category")["price"].transform("median")
df_par_cat = df_test.copy()
df_par_cat["price"] = df_par_cat["price"].fillna(mediane_par_cat)

# Méthode 3 : imputation GLOBALE (le moins bon)
mediane_globale = df_test["price"].median()
df_global = df_test.copy()
df_global["price"] = df_global["price"].fillna(mediane_globale)

# Comparer pour les lignes initialement NaN
lignes_nan = df_test["price"].isnull()

print("Comparaison des méthodes d'imputation (lignes initialement NaN) :")
print(f"{'Produit':30s} {'Réf':10s} {'Par produit':12s} {'Par catég':12s} {'Global':10s}")
print("-" * 80)

for idx in df_test[lignes_nan].sample(5, random_state=42).index:
    produit = df_test.loc[idx, "product_name"]
    ref_prix = df_test[df_test["product_name"] == produit]["price"].median()
    p1 = df_par_produit.loc[idx, "price"]
    p2 = df_par_cat.loc[idx, "price"]
    p3 = df_global.loc[idx, "price"]
    print(f"{str(produit):30s} {ref_prix:10.2f} {p1:12.2f} {p2:12.2f} {p3:10.2f}")

# Conclusion :
# - Par produit : l'imputation "Laptop Dell" -> médiane des Laptop Dell -> très précis
# - Par catégorie : "Laptop Dell" -> médiane de tous les Electroniques -> moins précis
# - Global : médiane de tous les prix -> très approximatif


── CORRIGÉ EXERCICE 3.7 — Test de mécanisme MCAR/MAR ─────────────────────

import pandas as pd
import numpy as np
from scipy.stats import chi2_contingency
import sys
sys.path.insert(0, ".")
from src.data_loader import charger_dataset

df = charger_dataset(verbose=False)

# Créer la variable indicatrice de manquance
df["age_manquant"] = df["customer_age"].isnull().astype(int)
# 1 = NaN, 0 = présent

# Tableau de contingence : age_manquant × payment_method
tableau = pd.crosstab(df["age_manquant"], df["payment_method"])
print("Tableau de contingence age_manquant × payment_method :")
print(tableau)

# Test du chi-carré
# H0 : Les NaN dans age sont indépendants du mode de paiement (MCAR)
# H1 : Les NaN sont liés au mode de paiement (MAR)
chi2, p_val, dof, expected = chi2_contingency(tableau)

print(f"\nTest du chi-carré :")
print(f"  chi² = {chi2:.4f}")
print(f"  ddl  = {dof}")
print(f"  p-value = {p_val:.4f}")

if p_val < 0.05:
    print("\n-> p < 0.05 : On REJETTE H0")
    print("-> Les NaN dépendent du mode de paiement -> MAR")
    print("-> Implication : imputer en tenant compte du mode de paiement")
else:
    print("\n-> p ≥ 0.05 : On ne peut pas rejeter H0")
    print("-> Les NaN semblent indépendants -> MCAR")
    print("-> Implication : imputation simple par médiane sans condition")

# Vérification visuelle : taux de NaN par mode de paiement
print("\nTaux de NaN par mode de paiement :")
print(df.groupby("payment_method")["age_manquant"].mean().round(3))
# Si les taux sont très différents -> lien avec le paiement -> MAR


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[IMPORTANT] RÉSUMÉ DE LA PARTIE 3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Vous avez maîtrisé :
  [OK] Audit complet : NaN, doublons, outliers, types
  [OK] Les 3 mécanismes de manquance : MCAR, MAR, MNAR
  [OK] Imputation : médiane globale, par groupe, indicateurs de manquance
  [OK] Suppression des doublons : exacts et partiels (clé métier)
  [OK] Détection outliers : IQR et Z-score
  [OK] Traitement outliers : winsorisation, suppression, flagging
  [OK] Pipeline de nettoyage complet avec validation par assertions
  [OK] Module data_cleaning.py professionnel et documenté

PROCHAINE ÉTAPE -> PARTIE 4 : Analyse Exploratoire (EDA)
  - Statistiques descriptives complètes
  - Distributions et tests de normalité
  - Corrélations entre variables
  - Dashboard EDA complet avec matplotlib et seaborn

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 3
================================================================================

================================================================================
  [GRAPHIQUE] DATAINSIGHT PRO — Plateforme Professionnelle d'Analyse de Données
  PARTIE 4 : Analyse Exploratoire des Données (EDA)
================================================================================
  "Avant de modéliser, il faut comprendre. Avant de comprendre, il faut explorer."
  Niveau : Intermédiaire
  Durée estimée : 5-6 heures
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le dataset est maintenant propre. Marc Dumont veut comprendre ses données
en profondeur avant de passer aux recommandations :

"Je veux savoir :
  -> Quelles sont les statistiques clés de nos ventes ?
  -> La distribution du panier moyen est-elle normale ou asymétrique ?
  -> Quel âge a notre client typique ?
  -> Nos variables numériques sont-elles corrélées ?
  -> Y a-t-il des patterns que l'œil nu ne voit pas dans le tableau ?"

Cette partie répond à toutes ces questions avec des statistiques
descriptives et des visualisations rigoureuses.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  [OK] Calculer toutes les statistiques descriptives (moyenne, médiane, IQR, skew...)
  [OK] Comprendre et appliquer les mesures de forme (skewness, kurtosis)
  [OK] Identifier les distributions des variables
  [OK] Calculer et interpréter les corrélations (Pearson, Spearman)
  [OK] Créer la matrice de corrélation et la heatmap
  [OK] Écrire le module analysis.py professionnel
  [OK] Identifier les insights clés et les formuler pour le business

CONCEPTS DU GUIDE UTILISÉS :
  - Chapitre 26 : Statistiques descriptives (tendance, dispersion, forme)
  - Chapitre 27 : Distributions (normale, log-normale, tests)
  - Chapitre 28 : Corrélations (Pearson, Spearman, Cramer's V)
  - Chapitre 29 : EDA automatisée et insights

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  THÉORIE APPLIQUÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

STATISTIQUES À CALCULER SUR SHOPMART :

  total_amount :
    Moyenne <- influencée par les outliers B2B
    Médiane <- valeur "typique" d'une commande
    Skewness <- probablement > 1 (queue vers les grosses commandes)
    IQR <- plage des 50% centraux des commandes

  customer_age :
    Distribution normalement symétrique (âges entre 18 et 80)
    CV (coefficient de variation) <- variabilité relative des âges

CORRÉLATION DANS CE CONTEXTE :
  price × total_amount    : Forte corrélation attendue (>0.8)
  quantity × total_amount : Corrélation modérée (les quantités varient peu)
  customer_age × price    : Faible corrélation attendue (âge n'influe pas le prix)

  ATTENTION : corrélation ≠ causalité !
  Une corrélation price/total_amount est triviale (l'une est dans l'autre)
  Une corrélation age/total_amount serait un vrai insight business.

CRAMER'S V (variables catégorielles) :
  region × payment_method  : Y a-t-il des modes de paiement régionaux ?
  category × is_returned   : Certaines catégories ont-elles plus de retours ?

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  IMPLÉMENTATION COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

══════════════════════════════════════════════════════════════════
FICHIER : src/analysis.py — Module complet d'analyse
══════════════════════════════════════════════════════════════════

"""
analysis.py

Responsabilité : Calculer les statistiques, corrélations et insights.
Ce module ne produit PAS de graphiques (c'est le rôle de visualization.py).
Il retourne des DataFrames de résultats prêts à être affichés ou tracés.
"""

import pandas as pd          # Manipulation de données
import numpy as np           # Calcul numérique
from scipy import stats      # Tests statistiques
from scipy.stats import chi2_contingency  # Test du chi-carré

from .utils import afficher_separateur, formater_euros, mesurer_temps


# ─────────────────────────────────────────────────────────────────────────────
# SECTION 1 : STATISTIQUES DESCRIPTIVES
# ─────────────────────────────────────────────────────────────────────────────

def statistiques_completes(serie, nom=None):
    """
    Calcule toutes les statistiques descriptives d'une variable numérique.

    Inclut :
      - Tendance centrale : moyenne, médiane, mode
      - Dispersion : variance, écart-type, IQR, CV
      - Forme : skewness, kurtosis
      - Percentiles : P5, P25, P50, P75, P95
      - Test de normalité : Shapiro-Wilk (sur échantillon)

    Args:
        serie (pd.Series) : La variable à analyser
        nom   (str)       : Nom descriptif (si None, utilise serie.name)

    Returns:
        dict : Dictionnaire avec toutes les statistiques calculées
    """
    nom = nom or serie.name or "Variable"
    s = serie.dropna()   # Enlever les NaN pour les calculs

    # ─── Tendance centrale ───
    moyenne = s.mean()         # Somme / n
    mediane = s.median()       # Valeur du milieu quand trié
    mode_val = s.mode().iloc[0] if len(s.mode()) > 0 else np.nan
    # mode() peut retourner plusieurs valeurs, on prend la première

    # ─── Dispersion ─────────
    variance = s.var()         # Somme des carrés des écarts / (n-1)
    ecart_type = s.std()       # Racine carrée de la variance
    q1 = s.quantile(0.25)
    q3 = s.quantile(0.75)
    iqr = q3 - q1
    cv = (ecart_type / moyenne * 100) if moyenne != 0 else np.nan
    # CV = Coefficient de Variation = std/mean × 100%

    # ─── Forme ──────────────
    skewness = s.skew()        # Asymétrie : 0=symétrique, >0=queue droite
    kurtosis = s.kurtosis()    # Aplatissement : 0=normal, >0=queues épaisses

    # ─── Percentiles ────────
    percentiles = {f"P{p}": s.quantile(p/100) for p in [1, 5, 10, 25, 50, 75, 90, 95, 99]}

    # ─── Test de normalité ──
    # Shapiro-Wilk : H0 = distribution normale
    # Limité à 5000 observations (test lent sur grands datasets)
    sample_size = min(len(s), 500)
    echantillon = s.sample(sample_size, random_state=42) if len(s) > sample_size else s
    shapiro_stat, shapiro_p = stats.shapiro(echantillon)
    est_normale = shapiro_p >= 0.05  # True si on ne peut pas rejeter la normalité

    # Assembler le résultat
    result = {
        "nom":         nom,
        "n":           len(s),
        "n_nan":       len(serie) - len(s),

        # Tendance centrale
        "moyenne":     round(moyenne, 4),
        "mediane":     round(mediane, 4),
        "mode":        round(mode_val, 4),

        # Dispersion
        "variance":    round(variance, 4),
        "ecart_type":  round(ecart_type, 4),
        "min":         round(s.min(), 4),
        "max":         round(s.max(), 4),
        "etendue":     round(s.max() - s.min(), 4),
        "q1":          round(q1, 4),
        "q3":          round(q3, 4),
        "iqr":         round(iqr, 4),
        "cv_pct":      round(cv, 2) if cv is not np.nan else "N/A",

        # Forme
        "skewness":    round(skewness, 4),
        "kurtosis":    round(kurtosis, 4),

        # Percentiles
        **{k: round(v, 4) for k, v in percentiles.items()},

        # Normalité
        "shapiro_stat": round(shapiro_stat, 4),
        "shapiro_p":    round(shapiro_p, 4),
        "est_normale":  est_normale,
    }

    return result


def afficher_statistiques(serie, nom=None):
    """Affiche les statistiques d'une variable de façon formatée."""
    stats_dict = statistiques_completes(serie, nom)

    afficher_separateur(f"Statistiques : {stats_dict['nom']}")

    print(f"\n  TAILLE")
    print(f"    n (valides) : {stats_dict['n']:,}")
    print(f"    n NaN       : {stats_dict['n_nan']}")

    print(f"\n  TENDANCE CENTRALE")
    print(f"    Moyenne     : {stats_dict['moyenne']}")
    print(f"    Médiane     : {stats_dict['mediane']}")
    print(f"    Mode        : {stats_dict['mode']}")
    print(f"    (Moyenne/Médiane = {stats_dict['moyenne']/stats_dict['mediane']:.3f})")
    # Ratio > 1.1 indique une asymétrie droite significative

    print(f"\n  DISPERSION")
    print(f"    Écart-type  : {stats_dict['ecart_type']}")
    print(f"    IQR         : {stats_dict['iqr']}  [Q1={stats_dict['q1']}, Q3={stats_dict['q3']}]")
    print(f"    CV          : {stats_dict['cv_pct']}%")
    print(f"    [Min, Max]  : [{stats_dict['min']}, {stats_dict['max']}]")

    # Interprétation du CV
    cv = stats_dict["cv_pct"]
    if isinstance(cv, (int, float)):
        if cv < 15:
            print(f"    -> Faible variabilité (CV < 15%)")
        elif cv < 30:
            print(f"    -> Variabilité modérée (15% ≤ CV < 30%)")
        else:
            print(f"    -> Forte variabilité (CV ≥ 30%) — distribution hétérogène")

    print(f"\n  FORME DE LA DISTRIBUTION")
    skew = stats_dict["skewness"]
    kurt = stats_dict["kurtosis"]
    print(f"    Skewness    : {skew}", end="")
    if abs(skew) < 0.5:
        print(" -> Symétrique")
    elif skew > 0:
        print(f" -> Asymétrie droite (queue à droite — présence de grandes valeurs)")
    else:
        print(f" -> Asymétrie gauche (queue à gauche — présence de petites valeurs)")

    print(f"    Kurtosis    : {kurt}", end="")
    if abs(kurt) < 0.5:
        print(" -> Mésokurtique (comme une normale)")
    elif kurt > 0:
        print(f" -> Leptokurtique (queues épaisses — événements extrêmes fréquents)")
    else:
        print(f" -> Platykurtique (queues fines — distribution aplatie)")

    print(f"\n  NORMALITÉ (Shapiro-Wilk, n={min(500, stats_dict['n'])})")
    print(f"    W = {stats_dict['shapiro_stat']}, p = {stats_dict['shapiro_p']}")
    if stats_dict["est_normale"]:
        print(f"    -> p ≥ 0.05 : Pas de preuve contre la normalité [OK]")
    else:
        print(f"    -> p < 0.05 : Distribution NON normale [ATTENTION]")
        print(f"    -> Recommandation : utiliser médiane et IQR (pas moyenne ± σ)")

    return stats_dict


def eda_complet(df):
    """
    Effectue une EDA complète sur le dataset ShopSmart.
    Calcule les statistiques de toutes les colonnes et retourne un rapport.

    Args:
        df (pd.DataFrame) : Dataset nettoyé

    Returns:
        dict : Rapport EDA complet
    """
    afficher_separateur("EDA COMPLÈTE — SHOPMART")
    rapport = {}

    # 1. Variables numériques
    cols_num = df.select_dtypes(include=[np.number]).columns
    rapport["stats_numeriques"] = {}
    for col in cols_num:
        rapport["stats_numeriques"][col] = statistiques_completes(df[col], col)

    # 2. Tableaux de fréquences pour les catégorielles
    cols_cat = df.select_dtypes(include=["object", "category"]).columns
    rapport["freq_categorielle"] = {}
    for col in cols_cat:
        vc = df[col].value_counts()
        pct = df[col].value_counts(normalize=True).round(4)
        rapport["freq_categorielle"][col] = pd.DataFrame({
            "effectif": vc,
            "frequence": pct
        })

    # 3. Taux de retour par catégorie (insight métier clé)
    rapport["taux_retour_categorie"] = (
        df.groupby("category")["is_returned"]
        .agg(["sum", "count", "mean"])
        .rename(columns={"sum": "n_retours", "count": "n_total", "mean": "taux_retour"})
        .sort_values("taux_retour", ascending=False)
    )
    rapport["taux_retour_categorie"]["taux_retour"] = rapport["taux_retour_categorie"]["taux_retour"].round(4)

    return rapport


# ─────────────────────────────────────────────────────────────────────────────
# SECTION 2 : CORRÉLATIONS
# ─────────────────────────────────────────────────────────────────────────────

def matrice_correlation(df, methode="pearson"):
    """
    Calcule la matrice de corrélation entre toutes les variables numériques.

    Méthodes disponibles :
      "pearson"  : Mesure les relations LINÉAIRES (sensible aux outliers)
      "spearman" : Mesure les relations MONOTONES (robuste aux outliers)
      "kendall"  : Plus robuste, plus lent pour grands datasets

    Quand utiliser Spearman plutôt que Pearson ?
      - Distribution non normale (skewness > 1)
      - Présence d'outliers
      - Variables ordinales

    Args:
        df     (pd.DataFrame) : Dataset
        methode (str)         : "pearson", "spearman", ou "kendall"

    Returns:
        pd.DataFrame : Matrice de corrélation symétrique
    """
    # Sélectionner uniquement les colonnes numériques
    cols_num = df.select_dtypes(include=[np.number]).columns
    df_num = df[cols_num].dropna()

    # .corr() calcule la matrice de corrélation
    # La diagonale est toujours 1.0 (une variable est parfaitement corrélée à elle-même)
    corr_matrix = df_num.corr(method=methode)

    print(f"\nMatrice de corrélation ({methode}) :")
    print(corr_matrix.round(3).to_string())

    return corr_matrix


def top_correlations(df, cible=None, n_top=10, methode="pearson", seuil=0.3):
    """
    Retourne les paires de variables les plus corrélées.

    Si cible est spécifié, retourne les corrélations avec cette variable.
    Sinon, retourne les top paires de l'ensemble de la matrice.

    Args:
        df     (pd.DataFrame) : Dataset
        cible  (str)          : Variable cible (ex: "total_amount")
        n_top  (int)          : Nombre de corrélations à retourner
        methode (str)         : "pearson" ou "spearman"
        seuil  (float)        : Corrélation minimale en valeur absolue

    Returns:
        pd.DataFrame : Top corrélations avec r, |r|, et interprétation
    """
    cols_num = df.select_dtypes(include=[np.number]).columns
    corr_matrix = df[cols_num].corr(method=methode)

    if cible:
        # Corrélations d'une variable avec toutes les autres
        if cible not in corr_matrix.columns:
            print(f"Attention : '{cible}' pas dans les colonnes numériques")
            return None

        corr_cible = corr_matrix[cible].drop(cible)
        df_corr = pd.DataFrame({
            "variable": corr_cible.index,
            "r": corr_cible.values,
            "|r|": corr_cible.abs().values
        }).sort_values("|r|", ascending=False)

    else:
        # Toutes les paires (triangle supérieur seulement pour éviter les doublons)
        paires = []
        n = len(corr_matrix)
        for i in range(n):
            for j in range(i+1, n):
                col_i = corr_matrix.index[i]
                col_j = corr_matrix.columns[j]
                r = corr_matrix.iloc[i, j]
                paires.append({"var1": col_i, "var2": col_j, "r": r, "|r|": abs(r)})

        df_corr = pd.DataFrame(paires).sort_values("|r|", ascending=False)

    # Filtrer selon le seuil
    df_corr = df_corr[df_corr["|r|"] >= seuil].head(n_top)

    # Ajouter l'interprétation
    def interpreter(r):
        ar = abs(r)
        direction = "positive" if r > 0 else "négative"
        if ar > 0.8:
            force = "Très forte"
        elif ar > 0.6:
            force = "Forte"
        elif ar > 0.4:
            force = "Modérée"
        elif ar > 0.2:
            force = "Faible"
        else:
            force = "Négligeable"
        return f"{force} {direction}"

    df_corr["interprétation"] = df_corr["r"].apply(interpreter)

    return df_corr


def cramers_v(x, y):
    """
    Calcule le V de Cramer entre deux variables catégorielles.

    V ∈ [0, 1] :
      0 = aucune association
      1 = association parfaite

    Permet de mesurer l'association entre deux variables catégorielles
    quand le r de Pearson n'est pas applicable.

    Args:
        x (pd.Series) : Première variable catégorielle
        y (pd.Series) : Deuxième variable catégorielle

    Returns:
        tuple : (V de Cramer, p-value du chi-carré)
    """
    # Tableau de contingence (croisement des deux variables)
    tableau = pd.crosstab(x, y)

    # Test du chi-carré de Pearson
    chi2, p, dof, _ = chi2_contingency(tableau)

    n = tableau.sum().sum()  # Taille totale de l'échantillon
    min_dim = min(tableau.shape) - 1  # min(nb_lignes - 1, nb_colonnes - 1)

    # V de Cramer = racine(chi² / (n × min_dim))
    v = np.sqrt(chi2 / (n * min_dim)) if min_dim > 0 else 0

    return round(v, 4), round(p, 6)


def matrice_association_categorielle(df):
    """
    Calcule la matrice de V de Cramer entre toutes les variables catégorielles.

    Équivalent à la matrice de corrélation Pearson mais pour les catégorielles.

    Args:
        df (pd.DataFrame) : Dataset

    Returns:
        pd.DataFrame : Matrice de V de Cramer
    """
    cols_cat = df.select_dtypes(include=["object", "category"]).columns.tolist()
    # Filtrer pour n'avoir que les colonnes avec peu de valeurs uniques (<20)
    cols_cat = [c for c in cols_cat if df[c].nunique() < 20]

    n = len(cols_cat)
    matrice = pd.DataFrame(np.zeros((n, n)), index=cols_cat, columns=cols_cat)

    for i in range(n):
        for j in range(n):
            if i == j:
                matrice.iloc[i, j] = 1.0  # Auto-association parfaite
            elif i < j:
                v, _ = cramers_v(df[cols_cat[i]], df[cols_cat[j]])
                matrice.iloc[i, j] = v
                matrice.iloc[j, i] = v  # Matrice symétrique

    return matrice


# ─────────────────────────────────────────────────────────────────────────────
# SECTION 3 : KPIS BUSINESS
# ─────────────────────────────────────────────────────────────────────────────

def calculer_kpis_globaux(df):
    """
    Calcule les KPIs (Key Performance Indicators) globaux de ShopSmart.

    Les KPIs sont des métriques business clés communiquées à la direction.
    Ils doivent être simples, compréhensibles, et actionnables.

    Args:
        df (pd.DataFrame) : Dataset nettoyé

    Returns:
        dict : Dictionnaire des KPIs
    """
    kpis = {}

    # ─── KPIs Financiers ────────────────────────────────────────────────────
    kpis["ca_total"] = df["total_amount"].sum()
    kpis["ca_moyen_mensuel"] = kpis["ca_total"] / 12
    kpis["panier_moyen"] = df["total_amount"].mean()
    kpis["panier_median"] = df["total_amount"].median()
    kpis["ca_par_client"] = kpis["ca_total"] / df["customer_id"].nunique()

    # ─── KPIs Volume ────────────────────────────────────────────────────────
    kpis["nb_commandes"] = len(df)
    kpis["nb_clients_uniques"] = df["customer_id"].nunique()
    kpis["nb_commandes_par_client"] = kpis["nb_commandes"] / kpis["nb_clients_uniques"]
    kpis["quantite_totale"] = df["quantity"].sum()

    # ─── KPIs Qualité ───────────────────────────────────────────────────────
    kpis["taux_retour_global"] = df["is_returned"].mean() * 100  # En %
    kpis["nb_retours"] = df["is_returned"].sum()

    # ─── KPIs Régionaux ─────────────────────────────────────────────────────
    ca_par_region = df.groupby("region")["total_amount"].sum()
    kpis["meilleure_region"] = ca_par_region.idxmax()
    kpis["ca_meilleure_region"] = ca_par_region.max()
    kpis["part_idf"] = df[df["region"] == "Île-de-France"]["total_amount"].sum() / kpis["ca_total"] * 100

    # ─── KPIs Produits ──────────────────────────────────────────────────────
    ca_par_cat = df.groupby("category")["total_amount"].sum()
    kpis["meilleure_categorie"] = ca_par_cat.idxmax()
    kpis["ca_meilleure_categorie"] = ca_par_cat.max()

    ca_par_produit = df.groupby("product_name")["total_amount"].sum()
    kpis["meilleur_produit"] = ca_par_produit.idxmax()
    kpis["ca_meilleur_produit"] = ca_par_produit.max()

    # Affichage formaté
    afficher_separateur("KPIs GLOBAUX — SHOPMART 2023")
    print(f"\n  [ARGENT] FINANCIERS")
    print(f"    CA Total             : {formater_euros(kpis['ca_total'])}")
    print(f"    CA Mensuel Moyen     : {formater_euros(kpis['ca_moyen_mensuel'])}")
    print(f"    Panier Moyen         : {formater_euros(kpis['panier_moyen'])}")
    print(f"    Panier Médian        : {formater_euros(kpis['panier_median'])}")
    print(f"    CA par Client        : {formater_euros(kpis['ca_par_client'])}")

    print(f"\n  [PACKAGE] VOLUME")
    print(f"    Commandes Totales    : {kpis['nb_commandes']:,}")
    print(f"    Clients Uniques      : {kpis['nb_clients_uniques']:,}")
    print(f"    Commandes/Client     : {kpis['nb_commandes_par_client']:.1f}")
    print(f"    Quantité Totale      : {kpis['quantite_totale']:,} articles")

    print(f"\n  [SYNC] QUALITÉ")
    print(f"    Taux de Retour       : {kpis['taux_retour_global']:.1f}%")
    print(f"    Nombre de Retours    : {kpis['nb_retours']:,}")

    print(f"\n  [WORLD_MAP]  RÉGIONS")
    print(f"    Meilleure Région     : {kpis['meilleure_region']} ({formater_euros(kpis['ca_meilleure_region'])})")
    print(f"    Part IDF             : {kpis['part_idf']:.1f}%")

    print(f"\n  [SHOPPING_TROLLEY] PRODUITS")
    print(f"    Meilleure Catégorie  : {kpis['meilleure_categorie']}")
    print(f"    Meilleur Produit     : {kpis['meilleur_produit']}")

    return kpis


def analyse_saisonnalite(df):
    """
    Analyse la saisonnalité mensuelle et trimestrielle des ventes.

    Retourne :
      - CA par mois avec taux de croissance
      - CA par trimestre
      - Jours de la semaine avec performance

    Args:
        df (pd.DataFrame) : Dataset avec colonne "date" en datetime

    Returns:
        dict : Tableaux d'analyse saisonnière
    """
    result = {}

    # ─── Analyse mensuelle ──────────────────────────────────────────────────
    df_mois = df.copy()
    df_mois["mois"] = df_mois["date"].dt.month
    df_mois["mois_nom"] = df_mois["date"].dt.strftime("%B")  # Nom complet du mois

    mensuel = df_mois.groupby("mois").agg(
        n_commandes    = ("order_id",     "count"),
        ca_total       = ("total_amount", "sum"),
        panier_moyen   = ("total_amount", "mean"),
        taux_retour    = ("is_returned",  "mean")
    ).round(2)

    # Taux de croissance mensuel (M/M)
    mensuel["croissance_mm"] = mensuel["ca_total"].pct_change() * 100
    mensuel["croissance_mm"] = mensuel["croissance_mm"].round(1)
    result["mensuel"] = mensuel

    # ─── Analyse par jour de semaine ────────────────────────────────────────
    df_mois["jour_semaine"] = df_mois["date"].dt.dayofweek
    jours_map = {0: "Lundi", 1: "Mardi", 2: "Mercredi", 3: "Jeudi",
                 4: "Vendredi", 5: "Samedi", 6: "Dimanche"}
    df_mois["nom_jour"] = df_mois["jour_semaine"].map(jours_map)

    hebdo = df_mois.groupby(["jour_semaine", "nom_jour"]).agg(
        n_commandes  = ("order_id",     "count"),
        ca_total     = ("total_amount", "sum"),
        panier_moyen = ("total_amount", "mean")
    ).reset_index().set_index("nom_jour").drop("jour_semaine", axis=1)
    result["hebdo"] = hebdo.sort_values("n_commandes", ascending=False)

    # Afficher le résumé
    afficher_separateur("ANALYSE SAISONNIÈRE")
    print("\nCA par mois :")
    print(mensuel[["ca_total", "croissance_mm"]].to_string())

    print("\nTop 3 jours par CA :")
    print(hebdo.nlargest(3, "ca_total")[["n_commandes", "ca_total", "panier_moyen"]])

    return result


# ─────────────────────────────────────────────────────────────────────────────
# TEST DIRECT
# ─────────────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import sys
    sys.path.insert(0, ".")
    from src.data_loader import charger_dataset
    from src.data_cleaning import pipeline_nettoyage

    # Charger et nettoyer
    df_brut  = charger_dataset("data/sales_data.csv", verbose=False)
    df_clean = pipeline_nettoyage(df_brut, verbose=False)

    # EDA complète
    rapport_eda = eda_complet(df_clean)

    # KPIs
    kpis = calculer_kpis_globaux(df_clean)

    # Statistiques détaillées sur total_amount
    afficher_statistiques(df_clean["total_amount"], "Montant des commandes (€)")

    # Corrélations numériques
    print("\n--- Corrélations numériques (Pearson) ---")
    corr_num = top_correlations(df_clean, cible="total_amount", n_top=8)
    print(corr_num.to_string(index=False))

    # Associations catégorielles
    print("\n--- Associations catégorielles (Cramer's V) ---")
    matrice_cat = matrice_association_categorielle(df_clean)
    print(matrice_cat.round(3).to_string())

    # Saisonnalité
    saisonnalite = analyse_saisonnalite(df_clean)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  ANALYSE ET INTERPRÉTATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÉSULTATS ATTENDUS SUR LE DATASET SHOPMART :

total_amount :
  Moyenne > Médiane  -> Distribution asymétrique à droite (skew > 0)
  CV ≈ 80-90%        -> Forte variabilité (des petits aux gros paniers)
  Non normale (p < 0.05 Shapiro)
  -> Recommandation : toujours citer la MÉDIANE (≈ 50€) et non la moyenne

customer_age :
  Skew ≈ 0            -> Distribution symétrique (généré avec normal())
  CV ≈ 25%            -> Variabilité modérée des âges
  Probablement normale ou quasi-normale

CORRÉLATIONS FORTES :
  price × total_amount : r ≈ 0.85+ (attendu : le prix est dans le total)
  quantity × total_amount : r ≈ 0.60 (logique)
  price × quantity : r ≈ 0.10 (faible : les prix élevés ne sont pas commandés
                                en grandes quantités)

ASSOCIATIONS CATÉGORIELLES :
  category × is_returned : Cramer's V ≈ 0.10-0.15 (faible association)
  region × payment_method : Cramer's V ≈ 0.05 (quasi-indépendants)
  -> Insight : le taux de retour ne dépend pas beaucoup de la catégorie

INSIGHT SAISONNALITÉ :
  Avec données générées aléatoirement : pas de vraie saisonnalité
  Sur données réelles : on verrait un pic en novembre/décembre (Black Friday)
  -> La méthode d'analyse (analyse_saisonnalite) est la bonne, les données manquent de réalisme

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8⃣  BONNES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. NE JAMAIS REPORTER UNIQUEMENT LA MOYENNE :
   Toujours accompagner la moyenne de la médiane ET de l'écart-type
   "CA moyen : 54€ (médiane : 38€, σ = 42€)" — bien plus informatif

2. TESTER LA NORMALITÉ AVANT DE CHOISIR UN TEST :
   Si non-normale -> utiliser tests non paramétriques (Mann-Whitney, Kruskal)
   Si normale -> t-test, ANOVA (plus puissants)

3. CORRÉLATION ≠ CAUSALITÉ — TOUJOURS RAPPELER :
   "price et total_amount sont fortement corrélés" est trivial
   (l'un est dans l'autre). Ce n'est pas un insight business.

4. DOCUMENTER L'ÉCHELLE :
   Toujours préciser l'unité : "CA en euros", "âge en années"
   Sinon les chiffres sont incompréhensibles hors contexte

5. UTILISER LE BON COEFFICIENT DE CORRÉLATION :
   Données non normales -> Spearman (plus robuste)
   Variables catégorielles -> Cramer's V
   Binaire vs continu -> Point-bisériale (disponible dans scipy.stats)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────────────────

Exercice 4.1 — Statistiques de base
  Sur la colonne "total_amount" du dataset nettoyé, calculez manuellement
  (sans statistiques_completes()) : moyenne, médiane, min, max, Q1, Q3, IQR.

Exercice 4.2 — Skewness interprétation
  Calculez le skewness de "price" et "customer_age".
  Interprétez : quelle distribution est la plus symétrique ?
  Quelle transformation recommandez-vous pour la moins symétrique ?

Exercice 4.3 — Corrélation rapide
  Calculez le r de Pearson entre "price" et "total_amount" avec .corr().
  Est-ce une corrélation forte ou faible ? Surprenant ou attendu ?

── NIVEAU INTERMÉDIAIRE ──────────────────────────────────────────────────────

Exercice 4.4 — Pearson vs Spearman
  Pour les colonnes "price" et "total_amount", calculez :
    a) r de Pearson avec df.corr(method="pearson")
    b) ρ de Spearman avec df.corr(method="spearman")
  Y a-t-il une grande différence ? Qu'est-ce que cela indique ?

Exercice 4.5 — Association catégorielle
  Calculez le V de Cramer entre "category" et "is_returned".
  Quel est le taux de retour par catégorie ?
  Y a-t-il une catégorie avec un taux anormalement élevé ?

Exercice 4.6 — Tableau de bord KPIs
  Créez un dictionnaire de KPIs pour CHAQUE RÉGION contenant :
    - CA total
    - Panier moyen
    - Taux de retour
    - Part du CA total (%)
  Triez par CA total décroissant.

── NIVEAU AVANCÉ ─────────────────────────────────────────────────────────────

Exercice 4.7 — Rapport EDA automatisé
  Écrivez une fonction generer_rapport_eda(df, chemin_sortie) qui :
    a) Calcule tous les KPIs
    b) Écrit un fichier Markdown "reports/eda_report.md" avec les résultats
    c) Inclut les statistiques descriptives, corrélations et insights

Exercice 4.8 — Analyse de segment
  Séparez le dataset en 2 groupes : "Gros paniers" (total_amount > médiane)
  et "Petits paniers". Comparez leurs distributions d'âge client avec
  un test statistique approprié. Y a-t-il une différence significative ?

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉ ULTRA DÉTAILLÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── CORRIGÉ EXERCICE 4.7 — Rapport EDA automatisé ────────────────────────────

def generer_rapport_eda(df, chemin_sortie="reports/eda_report.md"):
    """
    Génère un rapport EDA complet en format Markdown.

    Le Markdown est un format texte lisible par les humains et rendu
    en HTML par GitHub, Jupyter, VS Code, Notion, etc.

    Args:
        df (pd.DataFrame) : Dataset nettoyé
        chemin_sortie (str) : Chemin du fichier Markdown de sortie
    """
    from pathlib import Path
    import sys
    sys.path.insert(0, ".")
    from src.analysis import (calculer_kpis_globaux, statistiques_completes,
                               top_correlations, matrice_association_categorielle)
    from datetime import datetime

    # Créer le dossier reports s'il n'existe pas
    Path(chemin_sortie).parent.mkdir(parents=True, exist_ok=True)

    kpis = calculer_kpis_globaux(df)
    stats_total = statistiques_completes(df["total_amount"], "total_amount")
    stats_age   = statistiques_completes(df["customer_age"], "customer_age")

    # Construire le contenu Markdown
    # Les # sont des titres, ** est du gras, | est un tableau Markdown
    contenu = f"""# [GRAPHIQUE] Rapport EDA — DataInsight Pro / ShopSmart

**Généré le :** {datetime.now().strftime("%d/%m/%Y à %H:%M")}
**Dataset :** sales_data.csv — {len(df):,} lignes × {len(df.columns)} colonnes

---

## 1. Vue d'ensemble

| KPI | Valeur |
|-----|--------|
| Chiffre d'Affaires Total | {kpis['ca_total']:,.2f} € |
| Panier Moyen | {kpis['panier_moyen']:.2f} € |
| Panier Médian | {kpis['panier_median']:.2f} € |
| Nombre de Commandes | {kpis['nb_commandes']:,} |
| Clients Uniques | {kpis['nb_clients_uniques']:,} |
| Commandes / Client | {kpis['nb_commandes_par_client']:.1f} |
| Taux de Retour | {kpis['taux_retour_global']:.1f}% |

---

## 2. Distribution du Panier

| Statistique | total_amount | customer_age |
|-------------|--------------|--------------|
| Moyenne | {stats_total['moyenne']:.2f} | {stats_age['moyenne']:.2f} |
| Médiane | {stats_total['mediane']:.2f} | {stats_age['mediane']:.2f} |
| Écart-type | {stats_total['ecart_type']:.2f} | {stats_age['ecart_type']:.2f} |
| Skewness | {stats_total['skewness']:.3f} | {stats_age['skewness']:.3f} |
| Distribution normale | {'Oui' if stats_total['est_normale'] else 'Non'} | {'Oui' if stats_age['est_normale'] else 'Non'} |

**Interprétation :**
- Le panier moyen ({stats_total['moyenne']:.2f}€) est supérieur au médian ({stats_total['mediane']:.2f}€)
- -> Distribution asymétrique : quelques grosses commandes tirent la moyenne vers le haut
- -> Utiliser la **médiane** comme mesure de référence

---

## 3. Performance Régionale

| Région | CA (€) | Part (%) |
|--------|---------|----------|
"""
    # Ajouter le tableau régional
    ca_region = df.groupby("region")["total_amount"].sum().sort_values(ascending=False)
    ca_total = df["total_amount"].sum()
    for region, ca in ca_region.items():
        part = ca / ca_total * 100
        contenu += f"| {region} | {ca:,.2f} | {part:.1f}% |\n"

    contenu += f"""
---

## 4. Insights Clés

1. **Région dominante** : {kpis['meilleure_region']} représente {kpis['part_idf']:.1f}% du CA
2. **Meilleure catégorie** : {kpis['meilleure_categorie']}
3. **Taux de retour** : {kpis['taux_retour_global']:.1f}% — à surveiller

---

## 5. Recommandations

- Renforcer la présence en {kpis['meilleure_region']} (marché le plus porteur)
- Investiguer les retours par catégorie (voir Partie 7)
- Analyser les clients avec panier > {stats_total['q3']:.0f}€ (top 25%)

---
*Rapport généré automatiquement par DataInsight Pro*
"""

    # Écrire dans le fichier
    with open(chemin_sortie, "w", encoding="utf-8") as f:
        f.write(contenu)

    print(f"[OK] Rapport EDA généré : {chemin_sortie}")
    return chemin_sortie


── CORRIGÉ EXERCICE 4.8 — Test statistique entre segments ──────────────────

import pandas as pd
import numpy as np
from scipy import stats
import sys
sys.path.insert(0, ".")
from src.data_loader import charger_dataset
from src.data_cleaning import pipeline_nettoyage

df = charger_dataset(verbose=False)
df = pipeline_nettoyage(df, verbose=False)

# Séparer les groupes autour de la médiane
mediane = df["total_amount"].median()
gros_paniers  = df[df["total_amount"] > mediane]["customer_age"].dropna()
petits_paniers = df[df["total_amount"] <= mediane]["customer_age"].dropna()

print(f"Gros paniers (>{mediane:.0f}€)    : n={len(gros_paniers)}, âge moy={gros_paniers.mean():.1f}ans")
print(f"Petits paniers (<={mediane:.0f}€) : n={len(petits_paniers)}, âge moy={petits_paniers.mean():.1f}ans")

# Vérifier la normalité (sur échantillon de 500)
_, p_gros   = stats.shapiro(gros_paniers.sample(min(500, len(gros_paniers)), random_state=42))
_, p_petits = stats.shapiro(petits_paniers.sample(min(500, len(petits_paniers)), random_state=42))

print(f"\nShapiro p-value gros : {p_gros:.4f}")
print(f"Shapiro p-value petits : {p_petits:.4f}")

if p_gros < 0.05 or p_petits < 0.05:
    # Au moins un groupe non normal -> Test non paramétrique
    print("\nDistributions non normales -> Mann-Whitney U")
    u_stat, p_mann = stats.mannwhitneyu(gros_paniers, petits_paniers, alternative="two-sided")
    print(f"Mann-Whitney U = {u_stat:.0f}, p = {p_mann:.4f}")
    if p_mann < 0.05:
        print("-> Différence significative d'âge entre les segments [OK]")
    else:
        print("-> Pas de différence significative d'âge [X]")
else:
    # Deux groupes normaux -> t-test
    print("\nDistributions normales -> t-test de Student")
    t_stat, p_t = stats.ttest_ind(gros_paniers, petits_paniers)
    print(f"t = {t_stat:.3f}, p = {p_t:.4f}")
    if p_t < 0.05:
        print("-> Différence significative d'âge entre les segments [OK]")
    else:
        print("-> Pas de différence significative d'âge [X]")


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[IMPORTANT] RÉSUMÉ DE LA PARTIE 4
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Vous avez maîtrisé :
  [OK] Statistiques descriptives complètes : tendance, dispersion, forme, percentiles
  [OK] Interprétation du skewness et kurtosis dans un contexte business
  [OK] Tests de normalité (Shapiro-Wilk)
  [OK] Corrélations : Pearson, Spearman, V de Cramer
  [OK] KPIs business : CA, panier moyen, taux de retour, top régions
  [OK] Analyse de saisonnalité (par mois, trimestre, jour)
  [OK] Génération de rapport EDA automatisé en Markdown
  [OK] Module analysis.py professionnel, séparé du module de visualisation

PROCHAINE ÉTAPE -> PARTIE 5 : Visualisation des données
  - Matplotlib : histogrammes, scatter plots, boxplots, heatmaps
  - Seaborn : graphiques statistiques avancés
  - Dashboard complet de 12 graphiques

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FIN DE LA PARTIE 4
================================================================================

================================================================================
[GRAPHIQUE] DATAINSIGHT PRO — PARTIE 5
VISUALISATION DES DONNÉES
================================================================================
Projet : ShopSmart SARL — Analyse des ventes e-commerce
Fichier : src/visualization.py + notebooks/exploration.ipynb (section viz)
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER RÉEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Entreprise fictive : ShopSmart SARL
Secteur            : E-commerce (électronique, vêtements, maison)
Interlocuteur      : Fatou Diallo, Directrice Marketing

Après l'EDA de la partie 4, Fatou souhaite un DASHBOARD VISUEL complet
pour présenter les résultats au comité de direction.

Problématique business :
  "Nos tableaux Excel sont illisibles. Nous avons besoin de graphiques
   professionnels que je puisse inclure dans ma présentation PowerPoint
   et partager avec l'équipe."

Objectifs de cette partie :
  1. Créer des graphiques de distribution des ventes
  2. Visualiser les tendances temporelles
  3. Comparer les catégories et régions
  4. Construire un dashboard 12 graphiques en 1 figure
  5. Exporter en PNG haute résolution (300 dpi)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Compétences développées :
  [OK] Maîtriser l'interface OO de Matplotlib (Figure, Axes)
  [OK] Utiliser Seaborn pour des statistiques visuelles rapides
  [OK] Créer des subplots complexes avec GridSpec
  [OK] Personnaliser : couleurs, titres, annotations, légendes
  [OK] Exporter en PNG, PDF haute résolution
  [OK] Organiser le code de visualisation dans un module réutilisable

Concepts du guide couverts :
  -> Chapitre 30 : Matplotlib (anatomie, subplots, styles)
  -> Chapitre 31 : Seaborn (boxplot, violinplot, heatmap, barplot)
  -> Chapitre 32 : Plotly express (graphique interactif bonus)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  ÉNONCÉ DÉTAILLÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Tâches à réaliser :

TÂCHE 1 : Créer src/visualization.py avec les fonctions :
  - tracer_distribution_ca(df)         -> histogramme + KDE du CA
  - tracer_ventes_par_categorie(df)    -> barplot horizontal
  - tracer_evolution_mensuelle(df)     -> line chart temporel
  - tracer_heatmap_region_cat(df)      -> heatmap CA par région × catégorie
  - tracer_boxplot_prix_cat(df)        -> boxplot prix par catégorie
  - tracer_scatter_prix_quantite(df)   -> scatter avec couleur par catégorie
  - generer_dashboard_complet(df)      -> 12 graphiques en 1 figure
  - sauvegarder_graphique(fig, nom)    -> export PNG + PDF

TÂCHE 2 : Dans main.py, appeler generer_dashboard_complet() et sauvegarder

Contraintes :
  - Tous les graphiques doivent avoir titre, labels, légende
  - Couleurs cohérentes (palette définie en haut du fichier)
  - Fond blanc, grille légère, pas de spines inutiles
  - Taille : (20, 16) pour le dashboard complet
  - Résolution export : 300 dpi (qualité impression)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  THÉORIE APPLIQUÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

──────────────────────────────────────────
ARCHITECTURE MATPLOTLIB : Figure vs Axes
──────────────────────────────────────────

  Figure (la "feuille de papier") contient des Axes.
  Axes (le "graphique") contient les lignes, barres, etc.

  Il y a 2 façons de créer des graphiques :

  1. Interface pyplot (fonctionnelle, déconseillée en production) :
       plt.plot(x, y)
       plt.show()
     -> Implicite, difficile à contrôler quand plusieurs subplots

  2. Interface Orientée Objet (recommandée en production) :
       fig, ax = plt.subplots()
       ax.plot(x, y)
       fig.show()
     -> Explicite, on sait exactement quel Axes on modifie

  RÈGLE : Dans tous vos projets professionnels, utilisez TOUJOURS
          l'interface OO (fig, ax = plt.subplots()).

──────────────────────────────────────────
GUIDE : QUEL GRAPHIQUE POUR QUEL BESOIN ?
──────────────────────────────────────────

  DISTRIBUTION d'une variable continue :
    -> Histogramme + KDE (seaborn histplot avec kde=True)
    -> Boxplot (montre médiane, quartiles, outliers)
    -> Violinplot (distribution + boxplot combinés)

  COMPARAISON entre catégories :
    -> Bar chart (si < 10 catégories)
    -> Lollipop chart (si beaucoup de catégories)
    -> Boxplot groupé (si on veut voir la distribution)

  ÉVOLUTION dans le temps :
    -> Line chart (valeurs continues)
    -> Bar chart (si périodes discrètes)
    -> Area chart (pour montrer l'accumulation)

  CORRÉLATION entre deux variables numériques :
    -> Scatter plot
    -> Hexbin (si beaucoup de points)
    -> Regplot (scatter + ligne de régression)

  COMPOSITION (parts d'un tout) :
    -> Pie chart (SEULEMENT si < 5 catégories)
    -> Stacked bar chart (évolution de composition)
    -> Treemap (si hiérarchie)

  MATRICE de relations :
    -> Heatmap (matrice de corrélation ou pivot table)
    -> Pairplot (toutes les paires de variables)

──────────────────────────────────────────
PALETTES DE COULEURS : BONNES PRATIQUES
──────────────────────────────────────────

  SEQUENTIELLE (pour des valeurs croissantes) :
    Blues, Greens, YlOrRd, viridis, plasma
    Usage : heatmaps, gradients de valeurs

  DIVERGENTE (pour des valeurs autour d'un centre) :
    coolwarm, RdBu, PiYG
    Usage : corrélations (-1 à +1), variations (hausse/baisse)

  QUALITATIVE (pour des catégories distinctes) :
    Set1, Set2, tab10, Paired
    Usage : catégories, groupes

  DALTONIEN-FRIENDLY :
    viridis, cividis, magma (jamais rouge+vert ensemble !)

──────────────────────────────────────────
SEABORN : AXES-LEVEL VS FIGURE-LEVEL
──────────────────────────────────────────

  AXES-LEVEL (prend ax= comme paramètre) :
    sns.histplot(data=df, x='col', ax=axes[0])
    sns.boxplot(data=df, x='cat', y='val', ax=axes[1])
    -> S'intègre dans une Figure existante -> recommandé

  FIGURE-LEVEL (crée sa propre Figure) :
    sns.displot(data=df, x='col')
    sns.relplot(data=df, x='x', y='y', col='categorie')
    -> Crée automatiquement plusieurs sous-figures
    -> NE PREND PAS de paramètre ax=
    -> Moins flexible pour les dashboards custom

  RÈGLE : Pour les dashboards, utilisez les AXES-LEVEL.
           Pour les analyses rapides, les FIGURE-LEVEL sont pratiques.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  IMPLÉMENTATION COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FICHIER : src/visualization.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
"""
src/visualization.py
────────────────────
Module de visualisation pour ShopSmart SARL.
Rôle        : Créer tous les graphiques du projet
Responsabilité : Encapsuler la logique de visualisation
                 pour la réutilisabilité
Dépendances : matplotlib, seaborn, pandas, numpy
"""

# ── Imports ──────────────────────────────────────────────────────────
import matplotlib.pyplot as plt
# matplotlib : bibliothèque de base de visualisation Python
# pyplot : interface fonctionnelle (nous utiliserons aussi l'OO)

import matplotlib.gridspec as gridspec
# gridspec : permet des mises en page complexes avec subplots
# de tailles différentes

import seaborn as sns
# seaborn : visualisation statistique au-dessus de matplotlib
# travaille nativement avec les DataFrames pandas

import pandas as pd
import numpy as np
from pathlib import Path
# Imports standards déjà expliqués dans les parties précédentes

import warnings
warnings.filterwarnings('ignore')
# Supprime les avertissements non critiques pour un output propre

# ── Configuration globale ─────────────────────────────────────────────

# Appliquer un style global à TOUS les graphiques
plt.style.use('seaborn-v0_8-whitegrid')
# 'seaborn-v0_8-whitegrid' : fond blanc avec grille légère
# Autres options : 'dark_background', 'ggplot', 'fivethirtyeight'

# Paramètres rcParams (paramètres globaux de matplotlib)
plt.rcParams.update({
    'figure.facecolor': 'white',
    # Fond de la figure : blanc
    'axes.facecolor':   '#F8F9FA',
    # Fond du graphique : gris très légèrement coloré
    'font.size':        11,
    # Taille de police par défaut
    'axes.titlesize':   13,
    # Taille des titres de graphiques
    'axes.labelsize':   11,
    # Taille des labels d'axes
    'legend.fontsize':  9,
    # Taille de la légende
    'axes.spines.top':  False,
    # Supprimer le bord supérieur du graphique
    'axes.spines.right': False,
    # Supprimer le bord droit du graphique
})
# POURQUOI supprimer spines.top et spines.right ?
# C'est un choix esthétique moderne : les 4 bordures sont redondantes.
# Garder seulement bas (axe X) et gauche (axe Y) suffit.

# ── Palette de couleurs cohérente pour le projet ─────────────────────

PALETTE_CATEGORIES = {
    'Electronique':    '#2196F3',   # Bleu
    'Vêtements':       '#FF5722',   # Orange
    'Maison':          '#4CAF50',   # Vert
    'Alimentation':    '#9C27B0',   # Violet
}
# POURQUOI définir une palette globale ?
# Cohérence visuelle : toujours la même couleur pour la même catégorie
# dans tous les graphiques du rapport.

PALETTE_REGIONS = ['#1565C0', '#0288D1', '#00ACC1', '#00838F', '#006064']
# Dégradé de bleus pour les 5 régions françaises

COULEUR_PRINCIPALE = '#1976D2'
# Bleu professionnel pour les graphiques mono-couleur

COULEUR_ACCENT = '#FF6F00'
# Orange pour les éléments importants à mettre en valeur

# ── Dossier de sortie ────────────────────────────────────────────────

REPORTS_DIR = Path("reports/figures")
# Chemin vers le dossier de sauvegarde des graphiques
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
# mkdir(parents=True) : crée aussi les dossiers parents si inexistants
# exist_ok=True : ne lève pas d'erreur si le dossier existe déjà


# ════════════════════════════════════════════════════════════════════
# FONCTION 1 : DISTRIBUTION DU CA
# ════════════════════════════════════════════════════════════════════

def tracer_distribution_ca(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Trace la distribution du chiffre d'affaires (histogramme + KDE).

    Paramètres :
      df  : DataFrame avec colonne 'total_amount'
      ax  : Axes matplotlib (optionnel, créé si None)

    Retourne :
      ax : L'objet Axes pour permettre le chaining
    """
    # Créer les Axes si non fournis
    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 5))
    # PATTERN : toujours accepter ax= pour s'intégrer dans des dashboards

    # Filtrer les valeurs manquantes
    ca_data = df['total_amount'].dropna()
    # dropna() : ignorer les NaN pour le tracé

    # Histogramme avec seaborn
    sns.histplot(
        data=ca_data,
        # data : les données à distribuer
        bins=50,
        # bins : nombre de barres de l'histogramme
        kde=True,
        # kde=True : superposer la courbe de densité (KDE)
        color=COULEUR_PRINCIPALE,
        # color : couleur des barres
        alpha=0.7,
        # alpha : transparence [0=transparent, 1=opaque]
        edgecolor='white',
        # edgecolor : couleur des bordures entre barres
        linewidth=0.5,
        # linewidth : épaisseur des bordures
        ax=ax
        # ax : intégrer dans l'Axes fourni
    )

    # Ligne verticale pour la moyenne
    moyenne = ca_data.mean()
    # Calculer la moyenne
    ax.axvline(
        moyenne,
        color='red',
        linestyle='--',
        linewidth=2,
        label=f'Moyenne : {moyenne:.2f} €'
    )
    # axvline : ligne verticale à x=moyenne
    # linestyle='--' : ligne pointillée

    # Ligne verticale pour la médiane
    mediane = ca_data.median()
    ax.axvline(
        mediane,
        color='orange',
        linestyle=':',
        linewidth=2,
        label=f'Médiane : {mediane:.2f} €'
    )
    # axvline avec linestyle=':' : ligne en pointillés fins

    # Annotations pour skewness
    skew = ca_data.skew()
    ax.text(
        0.98, 0.95,
        # Position relative (98% de l'axe X, 95% de l'axe Y)
        f'Skewness : {skew:.3f}',
        transform=ax.transAxes,
        # transAxes : coordonnées relatives [0,1] × [0,1]
        ha='right', va='top',
        # ha='right' : alignement horizontal à droite
        fontsize=10,
        bbox=dict(
            boxstyle='round,pad=0.4',
            facecolor='white',
            edgecolor='gray',
            alpha=0.8
        )
        # bbox : fond blanc arrondi pour lisibilité
    )

    # Labels et titre
    ax.set_title("Distribution du Chiffre d'Affaires", fontweight='bold')
    ax.set_xlabel("Montant de la transaction (€)")
    ax.set_ylabel("Nombre de transactions")
    ax.legend(loc='upper right')
    # legend() : afficher la légende avec les labels définis via label=

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 2 : VENTES PAR CATÉGORIE
# ════════════════════════════════════════════════════════════════════

def tracer_ventes_par_categorie(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Bar chart horizontal du CA total par catégorie de produit.
    Inclut les valeurs sur les barres et le % du total.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 5))

    # Agrégation : CA total par catégorie
    ca_par_cat = (
        df.groupby('category')['total_amount']
        .sum()
        .sort_values(ascending=True)
        # ascending=True pour barres horizontales (la plus haute en haut)
    )
    # groupby('category') : regrouper par catégorie
    # ['total_amount'].sum() : sommer le CA
    # sort_values(ascending=True) : trier pour affichage logique

    ca_total = ca_par_cat.sum()
    # Total général pour calculer les pourcentages

    # Couleurs basées sur la palette définie
    couleurs = [
        PALETTE_CATEGORIES.get(cat, COULEUR_PRINCIPALE)
        for cat in ca_par_cat.index
    ]
    # list comprehension : couleur spécifique si dans la palette,
    # sinon couleur principale par défaut

    # Barres horizontales
    bars = ax.barh(
        ca_par_cat.index,
        # y : noms des catégories (axe vertical)
        ca_par_cat.values,
        # width : valeurs (longueur des barres)
        color=couleurs,
        edgecolor='white',
        linewidth=0.5,
        height=0.6
        # height : épaisseur des barres
    )

    # Ajouter valeurs et pourcentages sur les barres
    for bar, (cat, val) in zip(bars, ca_par_cat.items()):
        pct = val / ca_total * 100
        # Calculer le % de chaque catégorie
        ax.text(
            bar.get_width() + ca_total * 0.005,
            # Position X : légèrement à droite du bout de la barre
            bar.get_y() + bar.get_height() / 2,
            # Position Y : centre vertical de la barre
            f'{val:,.0f} € ({pct:.1f}%)',
            # Texte : valeur formatée + pourcentage
            va='center', ha='left', fontsize=9
        )

    ax.set_title("Chiffre d'Affaires par Catégorie", fontweight='bold')
    ax.set_xlabel("CA Total (€)")
    ax.set_ylabel("")
    # Pas de label Y car les catégories sont déjà visibles

    # Formater l'axe X avec des séparateurs de milliers
    ax.xaxis.set_major_formatter(
        plt.FuncFormatter(lambda x, p: f'{x/1000:.0f}k €')
    )
    # FuncFormatter : fonction personnalisée pour formater les ticks
    # x/1000 : diviser par 1000 pour afficher en "k"

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 3 : ÉVOLUTION MENSUELLE
# ════════════════════════════════════════════════════════════════════

def tracer_evolution_mensuelle(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Line chart de l'évolution du CA mensuel avec zone colorée.
    Inclut les taux de croissance mois/mois.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(12, 5))

    # Préparer les données mensuelles
    df_mensuel = df.copy()
    df_mensuel['date'] = pd.to_datetime(df_mensuel['date'])
    # Convertir la date en datetime si ce n'est pas déjà fait

    ca_mensuel = (
        df_mensuel
        .groupby(df_mensuel['date'].dt.to_period('M'))
        # to_period('M') : regrouper par mois (ex: "2023-01")
        ['total_amount']
        .sum()
        .reset_index()
    )
    ca_mensuel.columns = ['mois', 'ca']
    ca_mensuel['mois_str'] = ca_mensuel['mois'].astype(str)
    # Convertir Period en string pour l'affichage

    # Taux de croissance mensuel
    ca_mensuel['croissance'] = ca_mensuel['ca'].pct_change() * 100
    # pct_change() : ((valeur_actuelle / valeur_précédente) - 1)
    # * 100 : en pourcentage

    x = range(len(ca_mensuel))
    # Positions numériques pour l'axe X (0, 1, 2, ...)

    # Ligne principale
    ax.plot(
        x, ca_mensuel['ca'],
        color=COULEUR_PRINCIPALE,
        linewidth=2.5,
        marker='o',
        markersize=6,
        zorder=3
        # zorder=3 : afficher au premier plan (au-dessus de la zone)
    )

    # Zone colorée sous la courbe
    ax.fill_between(
        x, ca_mensuel['ca'],
        alpha=0.15,
        color=COULEUR_PRINCIPALE
    )
    # fill_between : colorier la zone entre la courbe et l'axe X=0

    # Zones hausses/baisses
    for i in range(1, len(ca_mensuel)):
        if ca_mensuel['croissance'].iloc[i] > 0:
            # Croissance positive -> vert
            ax.fill_between(
                [i-1, i],
                [ca_mensuel['ca'].iloc[i-1], ca_mensuel['ca'].iloc[i]],
                alpha=0.2, color='green'
            )
        else:
            # Croissance négative -> rouge
            ax.fill_between(
                [i-1, i],
                [ca_mensuel['ca'].iloc[i-1], ca_mensuel['ca'].iloc[i]],
                alpha=0.2, color='red'
            )

    # Configurer l'axe X
    ax.set_xticks(x)
    ax.set_xticklabels(
        ca_mensuel['mois_str'],
        rotation=45,
        # rotation : incliner les labels pour éviter le chevauchement
        ha='right'
        # ha='right' : alignement horizontal à droite pour la rotation
    )

    # Formater l'axe Y
    ax.yaxis.set_major_formatter(
        plt.FuncFormatter(lambda y, p: f'{y/1000:.0f}k')
    )

    ax.set_title("Évolution Mensuelle du CA", fontweight='bold')
    ax.set_xlabel("Mois")
    ax.set_ylabel("CA (k€)")

    # Ajouter la croissance moyenne
    croissance_moy = ca_mensuel['croissance'].mean()
    ax.text(
        0.02, 0.95,
        f'Croissance moy : {croissance_moy:+.1f}%/mois',
        transform=ax.transAxes,
        fontsize=9,
        color='green' if croissance_moy > 0 else 'red',
        va='top'
    )

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 4 : HEATMAP RÉGION × CATÉGORIE
# ════════════════════════════════════════════════════════════════════

def tracer_heatmap_region_cat(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Heatmap du CA moyen par région (lignes) × catégorie (colonnes).
    Utilise seaborn.heatmap avec annotations.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 6))

    # Créer la table pivot
    pivot = df.pivot_table(
        values='total_amount',
        # Valeur à agréger
        index='region',
        # Lignes : régions
        columns='category',
        # Colonnes : catégories
        aggfunc='mean',
        # Fonction : moyenne
        fill_value=0
        # Remplacer NaN par 0 si une combinaison n'existe pas
    ).round(2)

    # Heatmap avec seaborn
    sns.heatmap(
        pivot,
        annot=True,
        # annot=True : afficher les valeurs dans chaque cellule
        fmt='.0f',
        # fmt='.0f' : format entier (pas de décimales)
        cmap='YlOrRd',
        # cmap : palette de couleurs (jaune -> orange -> rouge)
        linewidths=0.5,
        # Lignes entre cellules pour lisibilité
        linecolor='white',
        cbar_kws={
            'label': 'CA moyen (€)',
            'shrink': 0.8
        },
        # cbar_kws : paramètres de la barre de couleur
        ax=ax,
        annot_kws={'fontsize': 9}
        # Taille de police des annotations
    )

    ax.set_title(
        "CA Moyen par Région × Catégorie",
        fontweight='bold', pad=15
    )
    ax.set_xlabel("Catégorie de Produit")
    ax.set_ylabel("Région")
    ax.tick_params(axis='x', rotation=30)
    ax.tick_params(axis='y', rotation=0)

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 5 : BOXPLOT PRIX PAR CATÉGORIE
# ════════════════════════════════════════════════════════════════════

def tracer_boxplot_prix_cat(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Boxplot du prix unitaire par catégorie.
    Montre médiane, quartiles, et outliers.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 5))

    # Ordre des catégories par médiane décroissante
    ordre = (
        df.groupby('category')['price']
        .median()
        .sort_values(ascending=False)
        .index
        .tolist()
    )
    # POURQUOI trier ? Un ordre logique facilite la lecture.

    sns.boxplot(
        data=df,
        x='category',
        # Variable catégorielle sur l'axe X
        y='price',
        # Variable numérique sur l'axe Y
        order=ordre,
        # Ordre des catégories
        palette=PALETTE_CATEGORIES,
        # Couleurs par catégorie
        width=0.5,
        # Largeur des boîtes
        flierprops={
            'marker': 'o',
            'markersize': 3,
            'alpha': 0.5,
            'markerfacecolor': 'gray'
        },
        # Style des points outliers
        medianprops={
            'color': 'black',
            'linewidth': 2
        },
        # Style de la ligne médiane
        ax=ax
    )

    # Annotations : médiane sur chaque boîte
    medianes = df.groupby('category')['price'].median()
    for i, cat in enumerate(ordre):
        ax.text(
            i, medianes[cat] + 5,
            f'{medianes[cat]:.0f} €',
            ha='center', va='bottom',
            fontsize=8, fontweight='bold',
            color='black'
        )

    ax.set_title("Distribution des Prix par Catégorie", fontweight='bold')
    ax.set_xlabel("Catégorie")
    ax.set_ylabel("Prix Unitaire (€)")
    ax.tick_params(axis='x', rotation=15)

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 6 : SCATTER PRIX VS QUANTITÉ
# ════════════════════════════════════════════════════════════════════

def tracer_scatter_prix_quantite(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Scatter plot Prix unitaire vs Quantité commandée.
    Coloré par catégorie, taille proportionnelle au CA.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 6))

    # Scatter avec seaborn
    sns.scatterplot(
        data=df.dropna(subset=['price', 'quantity']),
        x='price',
        # Axe X : prix unitaire
        y='quantity',
        # Axe Y : quantité commandée
        hue='category',
        # Couleur par catégorie
        palette=PALETTE_CATEGORIES,
        alpha=0.5,
        # Transparence pour voir la superposition
        s=30,
        # Taille des points
        edgecolor='none',
        # Pas de bordure (moins de bruit visuel)
        ax=ax
    )

    # Ligne de régression globale (tendance)
    df_clean = df.dropna(subset=['price', 'quantity'])
    z = np.polyfit(df_clean['price'], df_clean['quantity'], 1)
    # polyfit(x, y, degré) : calcule les coefficients du polynôme
    # degré=1 : droite (ax + b)
    p = np.poly1d(z)
    # poly1d : crée une fonction polynomiale à partir des coefficients

    x_line = np.linspace(df_clean['price'].min(), df_clean['price'].max(), 100)
    ax.plot(
        x_line, p(x_line),
        'k--', linewidth=1.5, alpha=0.7,
        label=f'Tendance (r={df_clean["price"].corr(df_clean["quantity"]):.2f})'
    )

    ax.set_title("Prix vs Quantité par Catégorie", fontweight='bold')
    ax.set_xlabel("Prix Unitaire (€)")
    ax.set_ylabel("Quantité Commandée")
    ax.legend(title='Catégorie', loc='upper right', fontsize=8)

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 7 : TOP 10 PRODUITS
# ════════════════════════════════════════════════════════════════════

def tracer_top_produits(df: pd.DataFrame, n: int = 10, ax=None) -> plt.Axes:
    """
    Bar chart horizontal des n produits avec le plus fort CA.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 6))

    top_n = (
        df.groupby('product_name')['total_amount']
        .sum()
        .nlargest(n)
        # nlargest(n) : garder les n plus grandes valeurs
        .sort_values(ascending=True)
        # trier croissant pour affichage horizontal (meilleur en haut)
    )

    # Barres dégradées du plus faible au plus fort
    couleurs = plt.cm.Blues(np.linspace(0.4, 0.9, n))
    # plt.cm.Blues : colormap Blues
    # np.linspace(0.4, 0.9, n) : n valeurs entre 0.4 et 0.9
    # -> dégradé de bleu clair à foncé

    bars = ax.barh(
        range(n),
        top_n.values,
        color=couleurs,
        edgecolor='white'
    )
    ax.set_yticks(range(n))
    ax.set_yticklabels(top_n.index, fontsize=9)

    # Valeurs sur les barres
    for i, (bar, val) in enumerate(zip(bars, top_n.values)):
        ax.text(
            bar.get_width() * 1.01,
            bar.get_y() + bar.get_height() / 2,
            f'{val:,.0f} €',
            va='center', ha='left', fontsize=8
        )

    ax.set_title(f"Top {n} Produits par CA Total", fontweight='bold')
    ax.set_xlabel("CA Total (€)")
    ax.xaxis.set_major_formatter(
        plt.FuncFormatter(lambda x, p: f'{x/1000:.0f}k')
    )

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 8 : RÉPARTITION MODES DE PAIEMENT
# ════════════════════════════════════════════════════════════════════

def tracer_modes_paiement(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Pie chart + donut des modes de paiement.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(8, 8))

    paiements = df['payment_method'].value_counts()
    couleurs_pie = ['#2196F3', '#FF5722', '#4CAF50', '#FF9800']

    # Wedges (parts du camembert)
    wedges, texts, autotexts = ax.pie(
        paiements.values,
        labels=paiements.index,
        autopct='%1.1f%%',
        # autopct : afficher le % sur chaque part
        # '%1.1f%%' : 1 décimale, symbole %
        startangle=90,
        # startangle : angle de départ (0° = 3h, 90° = 12h)
        colors=couleurs_pie,
        explode=[0.05] * len(paiements),
        # explode : décaler légèrement chaque part du centre
        pctdistance=0.75,
        # Distance du % depuis le centre [0=centre, 1=bord]
        shadow=True
        # shadow : ombre pour effet 3D léger
    )

    # Style des textes
    for autotext in autotexts:
        autotext.set_fontsize(10)
        autotext.set_fontweight('bold')

    # Cercle central (effet donut)
    centre_circle = plt.Circle(
        (0, 0), 0.40,
        fc='white'
    )
    ax.add_patch(centre_circle)
    # add_patch : ajouter une forme géométrique sur le graphique
    # Circle(centre, rayon, facecolor) : cercle blanc au centre -> donut

    # Texte central
    ax.text(
        0, 0,
        f'{len(df):,}\ntransactions',
        ha='center', va='center',
        fontsize=11, fontweight='bold'
    )

    ax.set_title("Répartition des Modes de Paiement", fontweight='bold')

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 9 : TAUX DE RETOUR PAR CATÉGORIE
# ════════════════════════════════════════════════════════════════════

def tracer_taux_retour(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Bar chart du taux de retour par catégorie avec ligne seuil.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(9, 5))

    taux_retour = (
        df.groupby('category')['is_returned']
        .mean() * 100
    ).sort_values(ascending=False)
    # .mean() sur une colonne booléenne = proportion de True
    # * 100 : en pourcentage

    # Couleurs conditionnelles (rouge si > 10%, sinon vert)
    couleurs_ret = [
        '#F44336' if v > 10 else '#4CAF50'
        for v in taux_retour.values
    ]

    bars = ax.bar(
        taux_retour.index,
        taux_retour.values,
        color=couleurs_ret,
        edgecolor='white',
        width=0.6
    )

    # Ligne seuil d'alerte
    ax.axhline(
        10,
        color='red',
        linestyle='--',
        linewidth=1.5,
        alpha=0.7,
        label='Seuil alerte (10%)'
    )

    # Valeurs sur les barres
    for bar, val in zip(bars, taux_retour.values):
        ax.text(
            bar.get_x() + bar.get_width() / 2,
            bar.get_height() + 0.2,
            f'{val:.1f}%',
            ha='center', va='bottom',
            fontsize=10, fontweight='bold'
        )

    ax.set_title("Taux de Retour par Catégorie", fontweight='bold')
    ax.set_xlabel("Catégorie")
    ax.set_ylabel("Taux de Retour (%)")
    ax.legend()
    ax.set_ylim(0, max(taux_retour.values) * 1.2)

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 10 : DISTRIBUTION ÂGE CLIENTS
# ════════════════════════════════════════════════════════════════════

def tracer_distribution_age(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Histogramme de l'âge des clients avec segmentation.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(9, 5))

    age_data = df['customer_age'].dropna()

    # Histogramme
    ax.hist(
        age_data,
        bins=20,
        color=COULEUR_PRINCIPALE,
        edgecolor='white',
        alpha=0.8
    )

    # Segments d'âge (zones colorées)
    segments = [
        (18, 25, '#E3F2FD', 'Jeunes (18-25)'),
        (25, 45, '#E8F5E9', 'Actifs (25-45)'),
        (45, 65, '#FFF3E0', 'Seniors (45-65)'),
    ]
    for debut, fin, couleur, label in segments:
        ax.axvspan(
            debut, fin,
            alpha=0.15,
            color=couleur.replace('#', ''),
            # axvspan : zone verticale colorée entre x=debut et x=fin
        )
        # Annotation du segment
        ax.text(
            (debut + fin) / 2, ax.get_ylim()[1] * 0.9,
            label,
            ha='center', fontsize=7, rotation=90,
            color='gray'
        )

    ax.set_title("Distribution de l'Âge des Clients", fontweight='bold')
    ax.set_xlabel("Âge")
    ax.set_ylabel("Nombre de clients")

    # Stats dans le coin
    stats_text = (
        f"Moy : {age_data.mean():.0f} ans\n"
        f"Méd : {age_data.median():.0f} ans\n"
        f"σ   : {age_data.std():.1f}"
    )
    ax.text(
        0.97, 0.95, stats_text,
        transform=ax.transAxes,
        ha='right', va='top', fontsize=9,
        family='monospace',
        # family='monospace' : police à chasse fixe (beau pour stats)
        bbox=dict(boxstyle='round', facecolor='white', alpha=0.8)
    )

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 11 : CA PAR RÉGION (MAP TEXTUELLE)
# ════════════════════════════════════════════════════════════════════

def tracer_ca_par_region(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Graphique lollipop du CA par région.
    Le lollipop est plus élégant que le bar chart pour les comparaisons.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(9, 5))

    ca_region = (
        df.groupby('region')['total_amount']
        .sum()
        .sort_values(ascending=True)
    )

    # Lollipop = ligne + point (plus moderne qu'une barre)
    for i, (region, val) in enumerate(ca_region.items()):
        # Ligne horizontale
        ax.plot(
            [0, val], [i, i],
            color=PALETTE_REGIONS[i % len(PALETTE_REGIONS)],
            linewidth=2, alpha=0.8
        )
        # Point terminal (le "bonbon")
        ax.scatter(
            val, i,
            color=PALETTE_REGIONS[i % len(PALETTE_REGIONS)],
            s=150, zorder=5
        )
        # zorder=5 : afficher les points au-dessus des lignes
        # Valeur
        ax.text(
            val * 1.01, i,
            f'{val/1000:.0f}k €',
            va='center', ha='left', fontsize=9
        )

    ax.set_yticks(range(len(ca_region)))
    ax.set_yticklabels(ca_region.index)
    ax.set_title("CA Total par Région (Lollipop)", fontweight='bold')
    ax.set_xlabel("CA Total (€)")
    ax.xaxis.set_major_formatter(
        plt.FuncFormatter(lambda x, p: f'{x/1000:.0f}k')
    )
    ax.set_xlim(0, ca_region.max() * 1.15)

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION 12 : KPI SUMMARY (SCORECARD)
# ════════════════════════════════════════════════════════════════════

def tracer_kpi_scorecard(df: pd.DataFrame, ax=None) -> plt.Axes:
    """
    Tableau de KPIs clés visuels (scorecard textuel).
    Pas de graphique à proprement parler, mais une mise en page visuelle.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 5))

    # Calculer les KPIs
    kpis = {
        'CA Total':       f"{df['total_amount'].sum():>15,.0f} €",
        'Nb Commandes':   f"{len(df):>15,}",
        'Panier Moyen':   f"{df['total_amount'].mean():>15.2f} €",
        'Taux Retour':    f"{df['is_returned'].mean()*100:>14.1f} %",
        'Clients Uniques':f"{df['customer_id'].nunique():>15,}",
        'Prix Moyen':     f"{df['price'].mean():>15.2f} €",
    }

    ax.axis('off')
    # axis('off') : masquer les axes pour un affichage textuel pur

    # Titre
    ax.text(
        0.5, 0.95, "[GRAPHIQUE] KPIs Principaux — ShopSmart 2023",
        transform=ax.transAxes,
        ha='center', va='top',
        fontsize=14, fontweight='bold',
        color='#1565C0'
    )

    # Afficher chaque KPI
    for i, (label, valeur) in enumerate(kpis.items()):
        ligne = i // 3
        # Ligne (0 ou 1)
        col = i % 3
        # Colonne (0, 1 ou 2)

        x = 0.17 + col * 0.33
        y = 0.65 - ligne * 0.4
        # Positions calculées pour une grille 2×3

        # Fond coloré
        fancy_box = plt.FancyBboxPatch(
            (x - 0.12, y - 0.15), 0.24, 0.28,
            boxstyle="round,pad=0.02",
            facecolor='#E3F2FD',
            edgecolor='#1976D2',
            transform=ax.transAxes,
            linewidth=1.5
        )
        ax.add_patch(fancy_box)
        # FancyBboxPatch : rectangle arrondi décoratif

        # Label du KPI
        ax.text(
            x, y + 0.12, label,
            transform=ax.transAxes,
            ha='center', va='center',
            fontsize=9, color='#616161'
        )
        # Valeur du KPI
        ax.text(
            x, y - 0.02, valeur,
            transform=ax.transAxes,
            ha='center', va='center',
            fontsize=11, fontweight='bold',
            color='#0D47A1', family='monospace'
        )

    return ax


# ════════════════════════════════════════════════════════════════════
# FONCTION PRINCIPALE : DASHBOARD 12 GRAPHIQUES
# ════════════════════════════════════════════════════════════════════

def generer_dashboard_complet(df: pd.DataFrame,
                               titre: str = "Dashboard ShopSmart 2023") -> plt.Figure:
    """
    Génère le dashboard complet (12 graphiques) en une seule Figure.

    Utilise GridSpec pour une mise en page professionnelle.

    Paramètres :
      df     : DataFrame nettoyé
      titre  : Titre général du dashboard

    Retourne :
      fig : La Figure matplotlib complète
    """
    print(f"[VIZ] Génération du dashboard '{titre}'...")

    # ── Créer la Figure ──────────────────────────────────────────────
    fig = plt.figure(figsize=(24, 20))
    # figsize=(24, 20) : grande figure pour 12 graphiques
    # 24 pouces de large, 20 pouces de hauteur

    # ── Titre principal ──────────────────────────────────────────────
    fig.suptitle(
        titre,
        fontsize=20,
        fontweight='bold',
        color='#1565C0',
        y=0.98
        # y=0.98 : position verticale (1.0 = haut)
    )

    # ── Définir la grille : 4 lignes × 3 colonnes ────────────────────
    gs = gridspec.GridSpec(
        4, 3,
        figure=fig,
        hspace=0.45,
        # hspace : espace vertical entre lignes
        wspace=0.35,
        # wspace : espace horizontal entre colonnes
        top=0.94,
        # top : limite supérieure de la grille
        bottom=0.05
        # bottom : limite inférieure de la grille
    )

    # ── Placer les 12 graphiques ─────────────────────────────────────

    # LIGNE 0
    ax1 = fig.add_subplot(gs[0, 0])
    # gs[ligne, colonne] : sélectionner la cellule
    tracer_distribution_ca(df, ax=ax1)

    ax2 = fig.add_subplot(gs[0, 1])
    tracer_ventes_par_categorie(df, ax=ax2)

    ax3 = fig.add_subplot(gs[0, 2])
    tracer_taux_retour(df, ax=ax3)

    # LIGNE 1 — graphique large (2 colonnes)
    ax4 = fig.add_subplot(gs[1, :])
    # gs[1, :] : ligne 1, TOUTES les colonnes (graphique pleine largeur)
    tracer_evolution_mensuelle(df, ax=ax4)

    # LIGNE 2
    ax5 = fig.add_subplot(gs[2, 0])
    tracer_heatmap_region_cat(df, ax=ax5)

    ax6 = fig.add_subplot(gs[2, 1])
    tracer_boxplot_prix_cat(df, ax=ax6)

    ax7 = fig.add_subplot(gs[2, 2])
    tracer_scatter_prix_quantite(df, ax=ax7)

    # LIGNE 3
    ax8 = fig.add_subplot(gs[3, 0])
    tracer_top_produits(df, n=8, ax=ax8)

    ax9 = fig.add_subplot(gs[3, 1])
    tracer_ca_par_region(df, ax=ax9)

    ax10 = fig.add_subplot(gs[3, 2])
    tracer_distribution_age(df, ax=ax10)

    print(f"[VIZ] Dashboard généré avec succès ({fig.get_size_inches()} pouces)")

    return fig


# ════════════════════════════════════════════════════════════════════
# FONCTION UTILITAIRE : SAUVEGARDE
# ════════════════════════════════════════════════════════════════════

def sauvegarder_graphique(fig: plt.Figure,
                           nom_fichier: str,
                           formats: list = None) -> list:
    """
    Sauvegarde une figure dans plusieurs formats.

    Paramètres :
      fig          : Figure matplotlib à sauvegarder
      nom_fichier  : Nom de base (sans extension)
      formats      : Liste des formats ['png', 'pdf', 'svg']
                     défaut : ['png']

    Retourne :
      chemins : Liste des chemins des fichiers créés
    """
    if formats is None:
        formats = ['png']

    chemins = []

    for fmt in formats:
        chemin = REPORTS_DIR / f"{nom_fichier}.{fmt}"

        fig.savefig(
            chemin,
            dpi=300 if fmt == 'png' else 150,
            # dpi=300 pour PNG (impression)
            # dpi=150 pour PDF/SVG (vectoriel, dpi moins important)
            bbox_inches='tight',
            # bbox_inches='tight' : couper les marges blanches
            facecolor='white',
            # facecolor='white' : fond blanc (pas transparent)
            format=fmt
            # Spécifier le format explicitement
        )

        chemins.append(str(chemin))
        print(f"  [SAVE] {chemin} ({fmt.upper()})")

    return chemins


# ════════════════════════════════════════════════════════════════════
# FONCTION BONUS : GRAPHIQUE INTERACTIF PLOTLY
# ════════════════════════════════════════════════════════════════════

def generer_graphique_interactif(df: pd.DataFrame,
                                  chemin_html: str = "reports/interactive_dashboard.html"):
    """
    Génère un graphique interactif avec Plotly Express.
    Export en HTML pour partage via email ou intranet.

    BONUS : Plotly génère des graphiques interactifs (zoom, hover...)
    """
    try:
        import plotly.express as px
        import plotly.graph_objects as go
        from plotly.subplots import make_subplots

        # CA mensuel
        df_temp = df.copy()
        df_temp['date'] = pd.to_datetime(df_temp['date'])
        df_temp['mois'] = df_temp['date'].dt.to_period('M').astype(str)

        ca_mensuel = df_temp.groupby('mois')['total_amount'].sum().reset_index()

        # Figure Plotly
        fig_plotly = make_subplots(
            rows=1, cols=2,
            subplot_titles=['Évolution mensuelle du CA',
                            'CA par catégorie (interactif)']
        )

        # Graphique 1 : ligne mensuelle
        fig_plotly.add_trace(
            go.Scatter(
                x=ca_mensuel['mois'],
                y=ca_mensuel['total_amount'],
                mode='lines+markers',
                name='CA mensuel',
                line=dict(color='#1976D2', width=2),
                fill='tozeroy',
                # fill='tozeroy' : remplir jusqu'à y=0
                fillcolor='rgba(25,118,210,0.1)'
            ),
            row=1, col=1
        )

        # Graphique 2 : barres par catégorie
        ca_cat = df_temp.groupby('category')['total_amount'].sum().reset_index()
        fig_plotly.add_trace(
            go.Bar(
                x=ca_cat['category'],
                y=ca_cat['total_amount'],
                name='CA catégorie',
                marker_color='#FF5722'
            ),
            row=1, col=2
        )

        fig_plotly.update_layout(
            title_text="Dashboard Interactif — ShopSmart 2023",
            title_font_size=16,
            height=450,
            template='plotly_white'
        )

        Path(chemin_html).parent.mkdir(parents=True, exist_ok=True)
        fig_plotly.write_html(chemin_html)
        print(f"  [PLOTLY] Graphique interactif : {chemin_html}")

    except ImportError:
        print("  [PLOTLY] Non installé. pip install plotly pour le dashboard interactif.")
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FICHIER : main.py — section visualisation
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# Dans main.py, après le chargement et nettoyage (parties 1-3) :

from src.visualization import (
    generer_dashboard_complet,
    sauvegarder_graphique,
    generer_graphique_interactif
)

def etape_5_visualisation(df_propre):
    """Étape 5 : Génération du dashboard visuel complet."""

    print("\n" + "="*60)
    print("ÉTAPE 5 — VISUALISATION")
    print("="*60)

    # Générer le dashboard
    fig = generer_dashboard_complet(
        df_propre,
        titre="Dashboard ShopSmart SARL — Analyse Ventes 2023"
    )

    # Sauvegarder en PNG et PDF
    sauvegarder_graphique(
        fig,
        "dashboard_complet",
        formats=['png', 'pdf']
    )

    # Dashboard interactif Plotly (bonus)
    generer_graphique_interactif(df_propre)

    print("\n[ETAPE 5] Visualisation terminée [OK]")
    print("  -> reports/figures/dashboard_complet.png")
    print("  -> reports/figures/dashboard_complet.pdf")
    print("  -> reports/interactive_dashboard.html")

    plt.close('all')
    # close('all') : fermer toutes les figures pour libérer la mémoire
    # IMPORTANT en batch pour éviter les fuites mémoire

    return True
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  EXPLICATION LIGNE PAR LIGNE (SÉLECTION DES POINTS CLÉS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

COMPRENDRE AX.AXVLINE() :
  ax.axvline(x=100, color='red', linestyle='--')
  -> Trace une ligne verticale infinie à x=100
  -> Utile pour marquer seuils, moyennes, médianes

COMPRENDRE AX.AXHLINE() :
  ax.axhline(y=10, color='red', linestyle='--')
  -> Ligne horizontale infinie à y=10
  -> Utile pour marquer des objectifs ou seuils

COMPRENDRE AX.AXVSPAN() :
  ax.axvspan(xmin=25, xmax=45, alpha=0.1, color='green')
  -> Zone rectangulaire entre x=25 et x=45
  -> Utile pour mettre en évidence des intervalles

COMPRENDRE TRANSFORM=AX.TRANSAXES :
  ax.text(0.98, 0.95, "texte", transform=ax.transAxes)
  -> Coordonnées RELATIVES : (0,0) = bas-gauche, (1,1) = haut-droite
  -> INDÉPENDANT des données : le texte reste en haut à droite
     quelle que soit l'échelle du graphique
  -> Vs sans transform : coordonnées en unités des données

COMPRENDRE ZORDER :
  ax.plot(x, y, zorder=3)    # Devant
  ax.fill_between(x, y, zorder=1)  # Derrière
  -> zorder : ordre de profondeur (z-axis)
  -> Plus grand = devant (comme les calques dans Photoshop)
  -> Par défaut : lignes=2, patches=1

COMPRENDRE GRIDSPEC :
  gs = gridspec.GridSpec(4, 3, hspace=0.4, wspace=0.3)
  ax = fig.add_subplot(gs[0, :])   # Ligne 0, TOUTES les colonnes
  ax = fig.add_subplot(gs[1, 1:])  # Ligne 1, colonnes 1 et 2
  ax = fig.add_subplot(gs[2:, 0])  # Lignes 2-3, colonne 0
  -> Permet des graphiques de tailles différentes dans la même Figure
  -> Beaucoup plus flexible que plt.subplots()

COMPRENDRE DPI :
  fig.savefig("fichier.png", dpi=300)
  -> dpi = dots per inch (points par pouce)
  -> dpi=72  : écran basse résolution
  -> dpi=150 : bon compromis web/présentation
  -> dpi=300 : qualité impression professionnelle
  -> La taille en pixels = figsize_pouces × dpi
  -> Figure 10×6 à 300 dpi = 3000×1800 pixels

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  ANALYSE ET INTERPRÉTATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

INSIGHTS VISUELS ATTENDUS POUR SHOPSMART :

1. DISTRIBUTION CA : Si skewness > 1, la distribution est asymétrique
   (quelques grosses commandes tirent la moyenne vers le haut).
   La médiane est plus représentative que la moyenne.

2. CA PAR CATÉGORIE : L'Electronique représente souvent 40-50% du CA
   dans les e-commerces. Identifier la catégorie "vache à lait".

3. ÉVOLUTION MENSUELLE : Saisonnalité attendue (pics en novembre/décembre
   pour Noël). La croissance MoM doit être positive en moyenne.

4. HEATMAP RÉGION × CATÉGORIE : Certaines régions affectionnent
   certaines catégories. Adapter les campagnes publicitaires par région.

5. BOXPLOT PRIX : Grande variance dans l'Electronique (de 50€ à 500€+),
   faible variance dans l'Alimentation. Impact sur la politique de prix.

6. TAUX DE RETOUR : Si Vêtements > 15%, c'est un problème de sizing.
   Si Electronique > 10%, peut indiquer des problèmes de qualité.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8⃣  BONNES PRATIQUES PROFESSIONNELLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÈGLE 1 : Chaque graphique doit avoir TITRE + LABELS + LÉGENDE
  Un graphique sans titre ou axes labellisés est inutilisable
  dans un rapport professionnel.

RÈGLE 2 : Utiliser toujours plt.close() ou plt.close('all')
  En batch (beaucoup de graphiques), ne pas fermer = fuite mémoire.
  Une figure Matplotlib consomme ~10-50 MB de RAM.

RÈGLE 3 : Jamais de pie chart avec > 5-6 catégories
  Au-delà, les parts deviennent illisibles.
  Préférer un bar chart avec les % annotés.

RÈGLE 4 : Palette daltonien-friendly
  8-10% des hommes sont daltoniens. Éviter rouge+vert ensemble.
  Utiliser viridis, cividis, ou des palettes vérifiées sur
  https://colorbrewer2.org

RÈGLE 5 : Cohérence des couleurs dans tout le rapport
  La catégorie "Electronique" doit TOUJOURS être bleue.
  Définir la palette en constante en haut du module.

RÈGLE 6 : Format PNG pour web/email, PDF pour documents imprimés
  PNG : raster (pixels), bonne résolution à 300 dpi
  PDF : vectoriel (infiniment scalable), parfait pour Word/LaTeX

RÈGLE 7 : Annotations sur les graphiques
  Toujours annoter les valeurs clés (max, min, tendance).
  Un directeur ne doit pas avoir à lire les axes pour comprendre.

RÈGLE 8 : tight_layout() ou bbox_inches='tight'
  Sans ça, les titres et labels sont souvent coupés à l'export.
  TOUJOURS appeler avant savefig().

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9⃣  ERREURS FRÉQUENTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ERREUR 1 : Modifier plt.rcParams sans revenir aux valeurs par défaut
  # Si vous changez le style dans une fonction, il persiste
  # dans tous les graphiques suivants !
  # Solution : utiliser un context manager
  with plt.style.context('seaborn-v0_8-whitegrid'):
      # Graphiques ici avec ce style
      pass
  # Après le with, style original restauré

ERREUR 2 : Utiliser plt.show() dans les fonctions de module
  # plt.show() bloque l'exécution jusqu'à fermeture du graphique
  # Dans un module, retourner la Figure/Axes et laisser main.py décider
  # MAUVAIS :
  def tracer(df):
      fig, ax = plt.subplots()
      ax.plot(...)
      plt.show()  # <- ne pas faire ça dans un module !
  # BON :
  def tracer(df, ax=None):
      if ax is None:
          fig, ax = plt.subplots()
      ax.plot(...)
      return ax  # <- retourner et laisser l'appelant décider

ERREUR 3 : Confondre fig.savefig() et plt.savefig()
  # plt.savefig() sauvegarde le graphique ACTIF (peut être le mauvais)
  # fig.savefig() sauvegarde CETTE figure spécifique
  # Avec l'interface OO, toujours utiliser fig.savefig()

ERREUR 4 : Graphique trop chargé
  # 12 couleurs différentes, 5 annotations, légende de 10 items
  # -> Illisible. Less is more.
  # Règle : si ça nécessite plus de 30 secondes pour comprendre,
  # c'est trop complexe. Simplifier ou diviser en plusieurs graphiques.

ERREUR 5 : Ne pas utiliser dropna() avant un graphique
  # Les NaN peuvent provoquer des erreurs dans certains graphiques
  # ou des graphiques avec des "trous" inattendus.
  # Toujours : df.dropna(subset=['colonne_tracée'])

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────

EX 5.1 — Violin plot des prix
  Créez une fonction tracer_violinplot_prix(df, ax=None) qui affiche
  un violinplot du prix par catégorie avec seaborn.
  Ajoutez split=False et inner='box'.
  Exportez en PNG à 150 dpi.

EX 5.2 — Barres empilées
  À partir du pivot table CA par région × catégorie, créez un stacked
  bar chart (barres empilées) montrant la composition du CA de chaque
  région par catégorie.

EX 5.3 — Graphique simple
  Créez un simple scatter plot de 'price' vs 'quantity' pour la
  catégorie 'Electronique' uniquement. Ajoutez titre et labels.

── NIVEAU INTERMÉDIAIRE ───────────────────────────────────────────

EX 5.4 — Dual axis (deux axes Y)
  Créez un graphique avec DEUX axes Y :
  - Axe Y gauche (bleu) : CA mensuel (barres)
  - Axe Y droit (rouge) : Nombre de commandes (ligne)
  Indice :
    fig, ax1 = plt.subplots()
    ax2 = ax1.twinx()  # Créer un second axe Y

EX 5.5 — Heatmap hebdomadaire
  Créez une heatmap Jour de la semaine × Heure de la journée
  du nombre de transactions (si la colonne hour existe,
  sinon l'extraire de la date).

EX 5.6 — Graphique interactif
  Avec Plotly Express, créez un scatter animé montrant l'évolution
  mensuelle du prix moyen par catégorie (animation_frame='mois').

── NIVEAU AVANCÉ ──────────────────────────────────────────────────

EX 5.7 — Dashboard PDF multi-pages
  Créez un rapport PDF de 3 pages :
  - Page 1 : KPI scorecard + évolution mensuelle
  - Page 2 : Analyse produits (top 10, boxplot prix)
  - Page 3 : Analyse géographique (heatmap, lollipop)
  Indice :
    from matplotlib.backends.backend_pdf import PdfPages
    with PdfPages('rapport.pdf') as pdf:
        fig1 = ...
        pdf.savefig(fig1)
        fig2 = ...
        pdf.savefig(fig2)

EX 5.8 — Graphique custom : Waterfall chart
  Créez un Waterfall chart (graphique cascade) montrant
  la décomposition du CA :
  CA Brut -> - Retours -> - Remises -> = CA Net
  Les barres positives sont vertes, les négatives sont rouges.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉ ULTRA DÉTAILLÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── CORRIGÉ EX 5.4 (Dual Axis) ────────────────────────────────────

```python
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

# Dataset simulé pour l'exemple
np.random.seed(42)
mois = pd.date_range('2023-01', '2023-12', freq='ME')
ca = np.random.uniform(50000, 120000, 12)
nb_commandes = np.random.randint(200, 600, 12)

df_mois = pd.DataFrame({
    'mois': mois,
    'ca': ca,
    'nb_commandes': nb_commandes
})

# ── Créer la figure ────────────────────────────────────────────
fig, ax1 = plt.subplots(figsize=(12, 6))
# ax1 : premier Axes (axe Y gauche)

# ── Axe Y gauche : CA (barres) ────────────────────────────────
couleur_ca = '#1976D2'
bars = ax1.bar(
    df_mois['mois'],
    df_mois['ca'],
    color=couleur_ca,
    alpha=0.7,
    width=20,
    # width en jours pour un DatetimeIndex
    label='CA mensuel'
)
ax1.set_xlabel("Mois", fontsize=11)
ax1.set_ylabel("Chiffre d'Affaires (€)", color=couleur_ca, fontsize=11)
ax1.tick_params(axis='y', labelcolor=couleur_ca)
# tick_params : personaliser les ticks d'un axe spécifique
# labelcolor : même couleur que la courbe -> aide à lire le bon axe
ax1.yaxis.set_major_formatter(
    plt.FuncFormatter(lambda y, p: f'{y/1000:.0f}k')
)

# ── Axe Y droit : Nombre de commandes (ligne) ─────────────────
ax2 = ax1.twinx()
# twinx() : crée un SECOND Axes qui PARTAGE l'axe X avec ax1
# Les deux axes ont le même x, mais des y indépendants

couleur_cmd = '#FF5722'
ax2.plot(
    df_mois['mois'],
    df_mois['nb_commandes'],
    color=couleur_cmd,
    linewidth=2.5,
    marker='D',
    # marker='D' : losange
    markersize=7,
    label='Nb commandes',
    zorder=5
)
ax2.set_ylabel("Nombre de Commandes", color=couleur_cmd, fontsize=11)
ax2.tick_params(axis='y', labelcolor=couleur_cmd)

# ── Légende combinée des deux axes ───────────────────────────
# Problème : ax1.legend() n'inclut pas les artistes de ax2
# Solution : récupérer les handles des deux axes et combiner
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(
    lines1 + lines2,   # Combiner les deux listes
    labels1 + labels2,
    loc='upper left',
    fontsize=9
)
# get_legend_handles_labels() retourne (handles, labels)
# handles : objets visuels (barre, ligne...)
# labels  : textes correspondants

# ── Titre et mise en forme ───────────────────────────────────
ax1.set_title("CA vs Nombre de Commandes (Dual Axis)", fontweight='bold')
ax1.tick_params(axis='x', rotation=45)
plt.tight_layout()
plt.savefig("dual_axis.png", dpi=150, bbox_inches='tight')
plt.show()

print("EXPLICATION DU DUAL AXIS :")
print("  ax1 = axe Y GAUCHE (bleu) : unité euros")
print("  ax2 = axe Y DROIT (rouge) : unité commandes")
print("  Les deux utilisent le MÊME axe X (mois)")
print("  ax1.twinx() partage l'axe X et crée un Y indépendant")
```

── CORRIGÉ EX 5.7 (PDF multi-pages) ─────────────────────────────

```python
from matplotlib.backends.backend_pdf import PdfPages
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import pandas as pd
import numpy as np

# Simuler les données (normalement chargées depuis main.py)
np.random.seed(42)
n = 500
df = pd.DataFrame({
    'category': np.random.choice(['Electronique','Vêtements','Maison'], n),
    'price': np.random.uniform(10, 500, n),
    'total_amount': np.random.uniform(20, 2000, n),
    'region': np.random.choice(['Nord','Sud','Est','Ouest','Centre'], n),
    'date': pd.date_range('2023-01-01', periods=n, freq='D')[:n]
})

def creer_rapport_pdf(df, chemin="reports/rapport_shopsmart.pdf"):
    """Crée un rapport PDF multi-pages avec matplotlib."""

    from pathlib import Path
    Path(chemin).parent.mkdir(parents=True, exist_ok=True)

    with PdfPages(chemin) as pdf:
        # PdfPages est un context manager qui gère le fichier PDF
        # Chaque pdf.savefig() ajoute une page au PDF

        print(f"Création du PDF : {chemin}")

        # ── PAGE 1 : KPIs + Évolution ──────────────────────────
        fig1, axes1 = plt.subplots(2, 1, figsize=(11, 8.5))
        # 11×8.5 pouces = format lettre américain (standard rapport)

        # KPIs textuels
        axes1[0].axis('off')
        kpis_text = (
            f"CA Total : {df['total_amount'].sum():>15,.0f} €\n"
            f"Commandes : {len(df):>14,}\n"
            f"Panier Moyen : {df['total_amount'].mean():>11.2f} €\n"
            f"Prix Moyen : {df['price'].mean():>13.2f} €"
        )
        axes1[0].text(
            0.5, 0.5, kpis_text,
            transform=axes1[0].transAxes,
            ha='center', va='center',
            fontsize=14, family='monospace',
            bbox=dict(boxstyle='round', facecolor='#E3F2FD', pad=1)
        )
        axes1[0].set_title("[GRAPHIQUE] RAPPORT SHOPSMART — KPIs 2023",
                            fontsize=16, fontweight='bold')

        # Évolution mensuelle
        df_temp = df.copy()
        df_temp['mois'] = df_temp['date'].dt.to_period('M').astype(str)
        ca_mois = df_temp.groupby('mois')['total_amount'].sum()
        axes1[1].plot(range(len(ca_mois)), ca_mois.values, 'b-o', linewidth=2)
        axes1[1].set_xticks(range(len(ca_mois)))
        axes1[1].set_xticklabels(ca_mois.index, rotation=45, ha='right')
        axes1[1].set_title("Évolution Mensuelle du CA")
        axes1[1].set_ylabel("CA (€)")

        plt.tight_layout()
        pdf.savefig(fig1, bbox_inches='tight')
        # pdf.savefig() : ajouter cette figure comme nouvelle page
        plt.close(fig1)
        # close() : libérer la mémoire après avoir ajouté au PDF

        # ── PAGE 2 : Analyse Produits ───────────────────────────
        fig2, axes2 = plt.subplots(1, 2, figsize=(11, 6))

        # Boxplot prix par catégorie
        import seaborn as sns
        sns.boxplot(data=df, x='category', y='price',
                    palette='Set2', ax=axes2[0])
        axes2[0].set_title("Distribution des Prix par Catégorie")

        # CA par catégorie
        ca_cat = df.groupby('category')['total_amount'].sum().sort_values()
        axes2[1].barh(ca_cat.index, ca_cat.values, color='steelblue')
        axes2[1].set_title("CA par Catégorie")

        plt.tight_layout()
        pdf.savefig(fig2, bbox_inches='tight')
        plt.close(fig2)

        # ── PAGE 3 : Analyse Géographique ──────────────────────
        fig3, axes3 = plt.subplots(1, 1, figsize=(11, 7))

        pivot = df.pivot_table(
            values='total_amount',
            index='region',
            columns='category',
            aggfunc='mean'
        )
        sns.heatmap(pivot, annot=True, fmt='.0f',
                    cmap='YlOrRd', ax=axes3)
        axes3.set_title("CA Moyen par Région × Catégorie")

        plt.tight_layout()
        pdf.savefig(fig3, bbox_inches='tight')
        plt.close(fig3)

        # ── Métadonnées du PDF ─────────────────────────────────
        d = pdf.infodict()
        d['Title'] = 'Rapport ShopSmart SARL 2023'
        d['Author'] = 'DataInsight Pro'
        d['Subject'] = 'Analyse des ventes e-commerce'
        # infodict() : ajouter des métadonnées au fichier PDF

    print(f"  Rapport PDF créé : {chemin} (3 pages)")

creer_rapport_pdf(df)
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[NOTE] RÉCAPITULATIF PARTIE 5
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Ce que vous avez appris :

[OK] Architecture Matplotlib (Figure -> Axes -> Artists)
[OK] Interface OO vs pyplot (toujours OO en production)
[OK] Toutes les fonctions de visualisation du module (12 graphiques)
[OK] Seaborn axes-level (intégrable dans dashboards custom)
[OK] GridSpec pour mises en page complexes
[OK] Annotations (text, axvline, axhline, axvspan)
[OK] Export PNG 300 dpi et PDF vectoriel
[OK] PDF multi-pages avec PdfPages
[OK] Dual axis avec twinx()
[OK] Plotly Express pour les dashboards interactifs
[OK] Bonnes pratiques (plt.close(), palettes cohérentes, labels)

Fichiers créés :
  src/visualization.py     <- Module complet de visualisation
  reports/figures/         <- Graphiques exportés
  reports/interactive_dashboard.html <- Dashboard Plotly

Prochaine étape : PARTIE 6 — Analyse Avancée (GroupBy, Pivot Tables,
  segmentation RFM clients, cohorts, comparaisons statistiques)

================================================================================
FIN PARTIE 5
================================================================================

================================================================================
[GRAPHIQUE] DATAINSIGHT PRO — PARTIE 6
ANALYSE AVANCÉE : GROUPBY, PIVOT TABLES, SEGMENTATION
================================================================================
Projet : ShopSmart SARL — Analyse approfondie des ventes
Fichier : src/analysis.py (extension avancée)
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER RÉEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Entreprise fictive : ShopSmart SARL
Interlocuteur      : Marc Dumont, Directeur Général

La direction souhaite aller plus loin que les statistiques basiques :

"Nous voulons comprendre NOS CLIENTS en profondeur.
 Qui sont nos meilleurs clients ? Quels produits achète-t-on ensemble ?
 Comment les ventes évoluent-elles par cohorte mensuelle ?
 Quel est notre segment le plus profitable ?"

Objectifs de cette partie :
  1. GroupBy avancé : multi-niveaux, transform, filter, apply
  2. Pivot tables pour des analyses croisées
  3. Segmentation RFM des clients (Récence, Fréquence, Montant)
  4. Analyse de cohortes (rétention mensuelle)
  5. Comparaisons statistiques entre groupes

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Compétences développées :
  [OK] GroupBy avancé : agg(), transform(), filter(), apply()
  [OK] Pivot tables et crosstab
  [OK] Stack, unstack, melt
  [OK] Segmentation RFM (technique e-commerce classique)
  [OK] Analyse de cohortes
  [OK] Tests statistiques pour comparer des groupes

Concepts du guide couverts :
  -> Chapitre 19 : GroupBy avancé (Split-Apply-Combine)
  -> Chapitre 21 : Pivot Tables et Crosstab
  -> Chapitres 33-35 : Tests statistiques

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  THÉORIE APPLIQUÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

──────────────────────────────────────────
GROUPBY : SPLIT-APPLY-COMBINE
──────────────────────────────────────────

  Le paradigme GroupBy (introduit par Hadley Wickham en R, adopté par Pandas)
  décompose toute opération en 3 étapes :

  SPLIT   : df.groupby('categorie')
            -> Diviser le DataFrame en autant de groupes que de catégories

  APPLY   : .sum() / .mean() / .agg() / .transform() / .apply()
            -> Appliquer une fonction à chaque groupe

  COMBINE : Le résultat est automatiquement recombine en un nouveau DataFrame

  Visualisation :

  DataFrame original (10 lignes) :
  ┌────────────┬────────────┬──────┐
  │ category   │ region     │ CA   │
  ├────────────┼────────────┼──────┤
  │ Electro    │ Nord       │ 100  │
  │ Vetements  │ Sud        │  50  │
  │ Electro    │ Sud        │ 150  │
  │ ...                            │
  └────────────┴────────────┴──────┘

  SPLIT par 'category' :
  ┌──────────────┐  ┌─────────────┐  ┌───────────┐
  │ Electronique │  │  Vêtements  │  │  Maison   │
  │ (4 lignes)   │  │  (3 lignes) │  │ (3 lignes)│
  └──────────────┘  └─────────────┘  └───────────┘

  APPLY : sum(CA) sur chaque groupe :
  ┌──────────────┐  ┌─────────────┐  ┌───────────┐
  │    580 €     │  │   210 €     │  │  195 €    │
  └──────────────┘  └─────────────┘  └───────────┘

  COMBINE : résultat final :
  ┌──────────────┬───────┐
  │ category     │ CA    │
  ├──────────────┼───────┤
  │ Electronique │   580 │
  │ Maison       │   195 │
  │ Vêtements    │   210 │
  └──────────────┴───────┘

──────────────────────────────────────────
AGG() vs TRANSFORM() vs APPLY()
──────────────────────────────────────────

  .agg() :
    - RÉDUIT chaque groupe à une ou plusieurs statistiques
    - Résultat : DataFrame plus petit (un index = un groupe)
    - Usage : résumés, tableaux de bord
    Exemple : df.groupby('cat')['CA'].agg(['mean', 'sum', 'count'])

  .transform() :
    - RETOURNE un résultat de MÊME TAILLE que le DataFrame original
    - Chaque ligne reçoit la statistique de SON groupe
    - Usage : ajouter une colonne calculée par groupe
    Exemple : df['CA_moy_cat'] = df.groupby('cat')['CA'].transform('mean')

  .apply() :
    - Fonction ARBITRAIRE sur chaque groupe (sous-DataFrame)
    - Plus flexible mais plus lente
    - Usage : logique complexe non expressible avec agg/transform
    Exemple : df.groupby('cat').apply(lambda g: g.nlargest(3, 'CA'))

──────────────────────────────────────────
SEGMENTATION RFM
──────────────────────────────────────────

  La segmentation RFM est une technique marketing classique.
  Elle mesure 3 dimensions de la relation client :

  R — RÉCENCE : Combien de jours depuis le dernier achat ?
    -> Client récent = plus susceptible d'acheter à nouveau

  F — FRÉQUENCE : Combien d'achats en total ?
    -> Client fréquent = fidèle, forte valeur relationnelle

  M — MONTANT (Monetary) : Combien a-t-il dépensé au total ?
    -> Gros acheteur = forte valeur économique

  Attribution d'un score 1-5 à chaque dimension :
    5 = excellent, 1 = faible

  Règle de scoring Récence : INVERSÉ (petit = meilleur)
    -> Récence = 1 jour -> score R = 5 (vient d'acheter)
    -> Récence = 365 jours -> score R = 1 (inactif depuis longtemps)

  Règle de scoring Fréquence et Montant : DIRECT (grand = meilleur)
    -> Fréquence = 50 achats -> score F = 5

  Score RFM final = R*100 + F*10 + M ou "R-F-M" sous forme de label

  Segments typiques :
  ┌─────────────────┬──────────────────────────────────────────────┐
  │ Champions       │ R=5, F=5, M=5 : meilleurs clients            │
  │ Loyaux          │ R=4-5, F=4-5  : fidèles mais pas top CA      │
  │ Potentiels      │ R=4-5, F=1-2  : nouveaux mais prometteurs    │
  │ À risque        │ R=2-3, F=3-5  : anciens bons clients inactifs│
  │ Hibernants      │ R=1, F=1-2    : perdus                       │
  └─────────────────┴──────────────────────────────────────────────┘

──────────────────────────────────────────
ANALYSE DE COHORTES
──────────────────────────────────────────

  Une cohorte = groupe de clients ayant fait leur premier achat
                le même mois.

  L'analyse de cohortes mesure la RÉTENTION :
  Quel % des clients d'une cohorte revient acheter 1, 2, 3... mois après ?

  Exemple de matrice de cohortes :
  ┌──────────────┬─────┬──────┬──────┬──────┐
  │ Cohorte      │ M+0 │ M+1  │ M+2  │ M+3  │
  ├──────────────┼─────┼──────┼──────┼──────┤
  │ Jan 2023     │100% │  42% │  28% │  21% │
  │ Feb 2023     │100% │  38% │  25% │  19% │
  │ Mar 2023     │100% │  45% │  31% │  23% │
  └──────────────┴─────┴──────┴──────┴──────┘

  À M+0 : 100% (par définition, c'est leur premier mois)
  À M+1 : 42% des clients de janvier reviennent en février
  À M+2 : 28% reviennent 2 mois plus tard...

  Si les taux chutent rapidement -> problème de rétention
  Si les taux restent stables -> forte fidélisation

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  IMPLÉMENTATION COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FICHIER : src/analysis.py (suite — partie avancée)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
"""
src/analysis.py — ANALYSE AVANCÉE
───────────────────────────────────
Extension de l'analyse avec GroupBy avancé, pivot tables,
segmentation RFM et analyse de cohortes.
"""

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
from typing import Tuple, Dict, Optional
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')


# ════════════════════════════════════════════════════════════════════
# SECTION A : GROUPBY AVANCÉ
# ════════════════════════════════════════════════════════════════════

def analyse_groupby_multi_niveaux(df: pd.DataFrame) -> pd.DataFrame:
    """
    Analyse multi-niveaux : plusieurs groupes + plusieurs métriques.
    Utilise la syntaxe Named Aggregation (pandas 0.25+).
    """
    print("\n[ANALYSE] GroupBy multi-niveaux...")

    # Agrégation avancée avec nom explicite pour chaque statistique
    rapport = df.groupby(['category', 'region']).agg(

        # Métriques de volume
        nb_commandes      = ('order_id', 'count'),
        # ('colonne', 'fonction') : syntaxe Named Aggregation
        # ordre : le nom du résultat = ('source', 'agrégateur')

        nb_clients_uniques = ('customer_id', 'nunique'),
        # nunique : nombre de valeurs distinctes

        # Métriques financières
        ca_total          = ('total_amount', 'sum'),
        ca_moyen          = ('total_amount', 'mean'),
        ca_median         = ('total_amount', 'median'),
        ca_max            = ('total_amount', 'max'),

        # Métriques produits
        prix_moyen        = ('price', 'mean'),
        quantite_totale   = ('quantity', 'sum'),

        # Métriques qualité
        taux_retour       = ('is_returned', 'mean'),
        # mean sur booléen = proportion de True

    ).round(2)
    # round(2) : arrondir à 2 décimales

    # Ajouter des métriques calculées post-agrégation
    rapport['panier_moyen'] = rapport['ca_total'] / rapport['nb_commandes']
    # Division de deux colonnes

    rapport['ca_par_client'] = rapport['ca_total'] / rapport['nb_clients_uniques']
    # Valeur moyenne par client unique

    rapport['taux_retour_pct'] = (rapport['taux_retour'] * 100).round(1)
    # Convertir proportion en pourcentage

    # Trier par CA total décroissant
    rapport = rapport.sort_values('ca_total', ascending=False)

    print(rapport)
    return rapport


def ajouter_colonnes_groupby(df: pd.DataFrame) -> pd.DataFrame:
    """
    Utilise transform() pour ajouter des colonnes calculées par groupe.
    Ne réduit PAS le DataFrame : retourne le même nombre de lignes.

    C'est la différence clé avec agg() :
    - agg()       : N lignes -> k lignes (une par groupe)
    - transform() : N lignes -> N lignes (même taille)
    """
    print("\n[ANALYSE] Ajout colonnes par groupe (transform)...")

    df_enrichi = df.copy()

    # CA moyen de la catégorie pour chaque ligne
    df_enrichi['ca_moy_categorie'] = df_enrichi.groupby('category')['total_amount'].transform('mean')
    # Chaque ligne reçoit la MOYENNE DE SA CATÉGORIE
    # Si ligne1 est 'Electronique', elle reçoit la moyenne de toutes les lignes 'Electronique'

    # Part de marché de chaque transaction dans sa catégorie
    ca_total_cat = df_enrichi.groupby('category')['total_amount'].transform('sum')
    df_enrichi['part_dans_categorie'] = (df_enrichi['total_amount'] / ca_total_cat * 100).round(2)
    # Exemple : cette commande représente 0.05% du CA de sa catégorie

    # Rang de la commande dans sa catégorie (du plus grand CA au plus petit)
    df_enrichi['rang_dans_categorie'] = df_enrichi.groupby('category')['total_amount'].transform(
        lambda x: x.rank(ascending=False, method='dense')
    )
    # rank() : attribue un rang à chaque valeur du groupe
    # ascending=False : rang 1 = valeur la plus grande
    # method='dense' : pas de saut de rang en cas d'égalité

    # Déviation par rapport à la moyenne du groupe (Z-score dans le groupe)
    def z_score_groupe(x):
        """Z-score = (valeur - moyenne) / écart-type du groupe."""
        return (x - x.mean()) / x.std()

    df_enrichi['z_score_ca_cat'] = df_enrichi.groupby('category')['total_amount'].transform(z_score_groupe)
    # Valeur > 2 -> transaction anormalement élevée pour cette catégorie
    # Valeur < -2 -> transaction anormalement basse

    print(f"Colonnes ajoutées : ca_moy_categorie, part_dans_categorie, rang_dans_categorie, z_score_ca_cat")
    print(df_enrichi[['order_id', 'category', 'total_amount',
                        'ca_moy_categorie', 'part_dans_categorie', 'z_score_ca_cat']].head(8))

    return df_enrichi


def filtrer_groupes_par_critere(df: pd.DataFrame,
                                 seuil_ca_min: float = 10000) -> pd.DataFrame:
    """
    Utilise filter() pour garder SEULEMENT les groupes satisfaisant un critère.

    Différence avec agg() et transform() :
    - filter() ne réduit pas et ne transforme pas
    - Il garde ou supprime des GROUPES entiers selon un critère

    Cas d'usage : "Analyser seulement les catégories qui font plus de X€"
    """
    print(f"\n[ANALYSE] Filtrage des groupes avec CA > {seuil_ca_min:,} €...")

    n_avant = len(df)
    df_filtre = df.groupby('category').filter(
        lambda groupe: groupe['total_amount'].sum() > seuil_ca_min
        # lambda groupe : fonction anonyme appliquée à chaque sous-DataFrame
        # Retourne True (garder) ou False (supprimer le groupe entier)
    )
    n_apres = len(df_filtre)

    categories_gardees = df_filtre['category'].unique()
    print(f"  Avant : {n_avant} lignes, {df['category'].nunique()} catégories")
    print(f"  Après : {n_apres} lignes, {len(categories_gardees)} catégories")
    print(f"  Catégories gardées : {list(categories_gardees)}")

    return df_filtre


# ════════════════════════════════════════════════════════════════════
# SECTION B : PIVOT TABLES
# ════════════════════════════════════════════════════════════════════

def creer_pivot_tables(df: pd.DataFrame) -> dict:
    """
    Crée plusieurs pivot tables d'analyse.
    Retourne un dictionnaire de DataFrames.
    """
    print("\n[ANALYSE] Création des pivot tables...")

    pivots = {}

    # ── Pivot 1 : CA par mois × catégorie ─────────────────────────
    df['mois'] = pd.to_datetime(df['date']).dt.to_period('M').astype(str)
    # to_period('M') : transformer en période mensuelle
    # astype(str) : convertir en string pour l'affichage

    pivots['ca_mois_categorie'] = pd.pivot_table(
        df,
        values='total_amount',
        # Valeur à agréger
        index='mois',
        # Lignes : mois
        columns='category',
        # Colonnes : catégories
        aggfunc='sum',
        # Somme du CA
        fill_value=0,
        # Remplacer NaN par 0 (pas de ventes = 0 €)
        margins=True,
        # margins=True : ajouter ligne et colonne Total
        margins_name='TOTAL'
    ).round(0)

    print("\nCA par mois × catégorie :")
    print(pivots['ca_mois_categorie'])

    # ── Pivot 2 : Nombre de commandes par région × mode paiement ──
    pivots['nb_cmd_region_paiement'] = pd.pivot_table(
        df,
        values='order_id',
        # Compter les commandes
        index='region',
        columns='payment_method',
        aggfunc='count',
        # count : nombre de valeurs non-NaN
        fill_value=0
    )

    print("\nNb commandes par région × mode paiement :")
    print(pivots['nb_cmd_region_paiement'])

    # ── Pivot 3 : Multi-valeurs ───────────────────────────────────
    pivots['multi'] = pd.pivot_table(
        df,
        values=['total_amount', 'quantity'],
        # Deux valeurs à la fois
        index='category',
        columns='region',
        aggfunc={
            'total_amount': 'mean',
            # CA moyen pour chaque cellule
            'quantity': 'sum'
            # Quantité totale
        }
    ).round(1)

    print("\nCA moyen et quantité par catégorie × région :")
    print(pivots['multi'].head())

    return pivots


def analyser_crosstab(df: pd.DataFrame) -> pd.DataFrame:
    """
    Analyse croisée entre deux variables catégorielles.
    Crosstab = version simplifiée du pivot table pour les fréquences.
    """
    print("\n[ANALYSE] Crosstab régions × catégories...")

    # Crosstab : fréquences brutes
    ct_brut = pd.crosstab(
        df['region'],
        df['category'],
        margins=True,
        margins_name='Total'
    )

    # Crosstab normalisé par ligne (% au sein de chaque région)
    ct_ligne = pd.crosstab(
        df['region'],
        df['category'],
        normalize='index'
        # normalize='index' : chaque ligne somme à 1 (100%)
        # normalize='columns' : chaque colonne somme à 1
        # normalize='all' : tout diviser par le total général
    ).round(3) * 100

    print("\nFréquences brutes :")
    print(ct_brut)

    print("\nRépartition % par région :")
    print(ct_ligne)

    # Test du chi-carré : la région influence-t-elle les achats de catégorie ?
    chi2, p_val, dof, expected = stats.chi2_contingency(
        pd.crosstab(df['region'], df['category'])
    )

    print(f"\nTest Chi² (région <-> catégorie) :")
    print(f"  χ² = {chi2:.2f}, ddl = {dof}, p = {p_val:.4f}")
    if p_val < 0.05:
        print(f"  [OK] Association significative : la région influence les achats de catégorie")
    else:
        print(f"  [X] Pas d'association significative")

    return ct_ligne


def stack_unstack_melt_demo(df: pd.DataFrame) -> None:
    """
    Démontre stack(), unstack(), melt() pour transformer la structure.
    Ces opérations permettent de passer de "wide" à "long" format et vice-versa.
    """
    print("\n[ANALYSE] Démonstrations stack/unstack/melt...")

    # Créer un pivot "wide" de base
    pivot = pd.pivot_table(
        df,
        values='total_amount',
        index='region',
        columns='category',
        aggfunc='sum',
        fill_value=0
    )

    print("\nFormat WIDE (pivot) :")
    print(pivot)

    # MELT : wide -> long (de large à long)
    # Utile pour les graphiques (seaborn préfère le format long)
    df_long = pivot.reset_index().melt(
        id_vars=['region'],
        # id_vars : colonnes à garder comme identifiants (pas à "fondre")
        var_name='category',
        # var_name : nom de la nouvelle colonne qui contiendra les anciens noms de colonnes
        value_name='ca'
        # value_name : nom de la nouvelle colonne des valeurs
    )

    print("\nFormat LONG (melt) :")
    print(df_long.head(10))

    # STACK : colonnes -> index hiérarchique
    pivot_stacked = pivot.stack()
    # stack() : transforme les colonnes en un second niveau d'index
    # Résultat : Series à 2 niveaux d'index (region, category)

    print("\nFormat STACKED :")
    print(pivot_stacked.head(8))

    # UNSTACK : inverse de stack
    pivot_back = pivot_stacked.unstack('category')
    # unstack('category') : remettre 'category' en colonnes
    # Retour au format wide original

    print("\nFormat UNSTACKED (retour au wide) :")
    print(pivot_back)

    print("\n[OK] Démonstration terminée")
    print("  WIDE -> LONG : melt()")
    print("  LONG -> WIDE : pivot_table() ou unstack()")
    print("  Colonnes -> Index : stack()")
    print("  Index -> Colonnes : unstack()")


# ════════════════════════════════════════════════════════════════════
# SECTION C : SEGMENTATION RFM
# ════════════════════════════════════════════════════════════════════

def calculer_rfm(df: pd.DataFrame,
                  date_reference: Optional[str] = None) -> pd.DataFrame:
    """
    Calcule les métriques RFM et segmente les clients.

    Paramètres :
      df             : DataFrame avec order_id, customer_id, date, total_amount
      date_reference : Date de référence pour le calcul de la récence
                       (défaut : date max du dataset)

    Retourne :
      DataFrame avec les scores et segments RFM par client
    """
    print("\n[RFM] Calcul de la segmentation RFM...")

    df_rfm = df.copy()
    df_rfm['date'] = pd.to_datetime(df_rfm['date'])

    # Date de référence (lendemain de la dernière commande)
    if date_reference is None:
        date_ref = df_rfm['date'].max() + pd.Timedelta(days=1)
    else:
        date_ref = pd.to_datetime(date_reference)

    print(f"  Date de référence : {date_ref.date()}")

    # ── Calcul des métriques brutes ───────────────────────────────
    rfm = df_rfm.groupby('customer_id').agg(
        # Pour chaque client :
        derniere_commande = ('date', 'max'),
        # Date du dernier achat
        nb_commandes      = ('order_id', 'nunique'),
        # Nombre de commandes distinctes
        ca_total          = ('total_amount', 'sum')
        # Montant total dépensé
    )

    # Récence = nb de jours depuis le dernier achat
    rfm['recence'] = (date_ref - rfm['derniere_commande']).dt.days
    # .dt.days : convertir timedelta en nombre de jours

    # Renommer pour clarté
    rfm = rfm.rename(columns={
        'nb_commandes': 'frequence',
        'ca_total':     'montant'
    })
    rfm = rfm[['recence', 'frequence', 'montant']]

    print(f"  Clients analysés : {len(rfm):,}")
    print(f"  Récence min/max  : {rfm['recence'].min()}/{rfm['recence'].max()} jours")
    print(f"  Fréquence min/max: {rfm['frequence'].min()}/{rfm['frequence'].max()} commandes")
    print(f"  Montant min/max  : {rfm['montant'].min():.0f}/{rfm['montant'].max():.0f} €")

    # ── Scoring 1-5 par quintile ──────────────────────────────────
    # pd.qcut : découpe en quantiles de tailles égales (quintiles = 5 groupes)

    # RÉCENCE : score INVERSÉ (petit = bon -> score élevé)
    rfm['score_R'] = pd.qcut(
        rfm['recence'],
        q=5,
        labels=[5, 4, 3, 2, 1]
        # Score 5 = récence la plus courte (achat très récent)
        # Score 1 = récence la plus longue (achat très ancien)
    )

    # FRÉQUENCE : score DIRECT (grand = bon -> score élevé)
    rfm['score_F'] = pd.qcut(
        rfm['frequence'].rank(method='first'),
        # .rank(method='first') : gérer les ex-aequo en attribuant des rangs différents
        q=5,
        labels=[1, 2, 3, 4, 5]
        # Score 5 = fréquence la plus haute
    )

    # MONTANT : score DIRECT
    rfm['score_M'] = pd.qcut(
        rfm['montant'].rank(method='first'),
        q=5,
        labels=[1, 2, 3, 4, 5]
    )

    # Score RFM global (string "R-F-M" pour catégorisation)
    rfm['score_R'] = rfm['score_R'].astype(int)
    rfm['score_F'] = rfm['score_F'].astype(int)
    rfm['score_M'] = rfm['score_M'].astype(int)

    rfm['rfm_score'] = rfm['score_R'].astype(str) + \
                       rfm['score_F'].astype(str) + \
                       rfm['score_M'].astype(str)
    # "554" = Récent, très fréquent, gros acheteur -> Champion

    rfm['rfm_total'] = rfm['score_R'] + rfm['score_F'] + rfm['score_M']
    # Score total sur 15 (max = 5+5+5 = 15)

    # ── Segmentation en catégories business ──────────────────────
    def definir_segment(row):
        """Assigne un segment métier basé sur les scores RFM."""
        r, f, m = row['score_R'], row['score_F'], row['score_M']

        if r >= 4 and f >= 4 and m >= 4:
            return 'Champions'
        elif r >= 3 and f >= 3 and m >= 3:
            return 'Clients Loyaux'
        elif r >= 4 and f <= 2:
            return 'Nouveaux Prometteurs'
        elif r <= 2 and f >= 3 and m >= 3:
            return 'À Risque'
        elif r <= 2 and f <= 2:
            return 'Clients Perdus'
        elif r >= 3 and m >= 4:
            return 'Gros Acheteurs Occasionnels'
        else:
            return 'Clients Standards'

    rfm['segment'] = rfm.apply(definir_segment, axis=1)
    # apply(func, axis=1) : appliquer la fonction sur chaque LIGNE
    # axis=1 : ligne par ligne (vs axis=0 : colonne par colonne)

    # ── Résumé par segment ────────────────────────────────────────
    resume_segments = rfm.groupby('segment').agg(
        nb_clients  = ('recence', 'count'),
        rec_moy     = ('recence', 'mean'),
        freq_moy    = ('frequence', 'mean'),
        montant_moy = ('montant', 'mean'),
        montant_tot = ('montant', 'sum')
    ).round(1).sort_values('montant_tot', ascending=False)

    print("\n  RÉSUMÉ PAR SEGMENT :")
    print(resume_segments.to_string())

    # Distribution des segments
    distribution = rfm['segment'].value_counts()
    print(f"\n  Distribution : {distribution.to_dict()}")

    return rfm


def visualiser_rfm(rfm: pd.DataFrame) -> plt.Figure:
    """
    Crée un dashboard visuel de la segmentation RFM.
    """
    fig, axes = plt.subplots(2, 2, figsize=(14, 10))
    fig.suptitle("Segmentation RFM — Clients ShopSmart", fontsize=15, fontweight='bold')

    # ── Graphique 1 : Scatter R vs M coloré par segment ───────────
    palette_seg = {
        'Champions': '#FFD700',
        'Clients Loyaux': '#4CAF50',
        'Nouveaux Prometteurs': '#2196F3',
        'À Risque': '#FF5722',
        'Clients Perdus': '#9E9E9E',
        'Gros Acheteurs Occasionnels': '#9C27B0',
        'Clients Standards': '#78909C'
    }

    for segment, groupe in rfm.groupby('segment'):
        axes[0][0].scatter(
            groupe['recence'],
            groupe['montant'],
            label=segment,
            alpha=0.6, s=20,
            color=palette_seg.get(segment, 'gray')
        )
    axes[0][0].set_xlabel("Récence (jours)")
    axes[0][0].set_ylabel("Montant Total (€)")
    axes[0][0].set_title("Récence vs Montant par Segment")
    axes[0][0].legend(fontsize=7, loc='upper right')

    # ── Graphique 2 : Nb clients par segment ──────────────────────
    seg_count = rfm['segment'].value_counts()
    couleurs_seg = [palette_seg.get(s, 'gray') for s in seg_count.index]
    axes[0][1].barh(
        seg_count.index,
        seg_count.values,
        color=couleurs_seg,
        edgecolor='white'
    )
    for i, val in enumerate(seg_count.values):
        axes[0][1].text(
            val + 1, i,
            f'{val} ({val/len(rfm)*100:.1f}%)',
            va='center', fontsize=8
        )
    axes[0][1].set_title("Nombre de Clients par Segment")
    axes[0][1].set_xlabel("Nb clients")

    # ── Graphique 3 : Montant moyen par segment ────────────────────
    montant_seg = rfm.groupby('segment')['montant'].mean().sort_values(ascending=True)
    axes[1][0].barh(
        montant_seg.index,
        montant_seg.values,
        color=[palette_seg.get(s, 'gray') for s in montant_seg.index],
        edgecolor='white'
    )
    axes[1][0].set_title("Montant Moyen par Segment")
    axes[1][0].set_xlabel("Montant Moyen (€)")
    axes[1][0].xaxis.set_major_formatter(
        plt.FuncFormatter(lambda x, p: f'{x:.0f}€')
    )

    # ── Graphique 4 : Distribution des scores RFM ─────────────────
    rfm[['score_R', 'score_F', 'score_M']].mean().plot(
        kind='bar',
        ax=axes[1][1],
        color=['#2196F3', '#FF5722', '#4CAF50'],
        edgecolor='white'
    )
    axes[1][1].set_title("Score Moyen R/F/M\n(5 = meilleur)")
    axes[1][1].set_xlabel("Dimension")
    axes[1][1].set_ylabel("Score moyen (1-5)")
    axes[1][1].set_ylim(0, 5)
    axes[1][1].set_xticklabels(['Récence', 'Fréquence', 'Montant'], rotation=0)

    plt.tight_layout()
    return fig


# ════════════════════════════════════════════════════════════════════
# SECTION D : ANALYSE DE COHORTES
# ════════════════════════════════════════════════════════════════════

def analyser_cohortes(df: pd.DataFrame) -> Tuple[pd.DataFrame, plt.Figure]:
    """
    Analyse de cohortes : mesure la rétention mensuelle des clients.

    Étapes :
    1. Identifier la cohorte de chaque client (mois du 1er achat)
    2. Calculer l'âge de la commande (nb de mois depuis la cohorte)
    3. Construire la matrice de rétention

    Retourne :
      (matrice de rétention en %, figure matplotlib)
    """
    print("\n[COHORTES] Analyse de rétention mensuelle...")

    df_coh = df.copy()
    df_coh['date'] = pd.to_datetime(df_coh['date'])
    df_coh['mois_commande'] = df_coh['date'].dt.to_period('M')
    # to_period('M') : transformer la date en période mensuelle

    # ── Cohorte de chaque client : son premier mois d'achat ───────
    premier_achat = df_coh.groupby('customer_id')['mois_commande'].min()
    # Pour chaque client : la plus petite (min) période = première commande
    df_coh['cohorte'] = df_coh['customer_id'].map(premier_achat)
    # map(dictionnaire) : remplacer chaque customer_id par sa cohorte

    # ── Âge de la commande ────────────────────────────────────────
    df_coh['age_cohorte'] = (
        df_coh['mois_commande'] - df_coh['cohorte']
    ).apply(lambda x: x.n)
    # .apply(lambda x: x.n) : extraire le nombre de mois entre les deux périodes
    # x.n : attribut d'un PeriodOffset (nombre de périodes)

    # ── Matrice de rétention brute (nb de clients uniques) ────────
    matrice_brute = df_coh.groupby(
        ['cohorte', 'age_cohorte']
    )['customer_id'].nunique().reset_index()
    # Pour chaque (cohorte, age), compter les clients uniques

    # Pivoter : cohortes en lignes, age en colonnes
    matrice_pivot = matrice_brute.pivot(
        index='cohorte',
        columns='age_cohorte',
        values='customer_id'
    )

    # ── Convertir en % de rétention ───────────────────────────────
    # Taille de la cohorte au mois 0 (acquisition)
    taille_cohorte = matrice_pivot[0]
    # matrice_pivot[0] : colonne 'age=0' = clients du premier mois

    matrice_pct = matrice_pivot.divide(taille_cohorte, axis=0) * 100
    # divide(valeurs, axis=0) : diviser chaque ligne par la taille de sa cohorte
    # axis=0 : opération ligne par ligne
    matrice_pct = matrice_pct.round(1)

    # Garder seulement les premières colonnes (les plus significatives)
    max_age = min(12, matrice_pct.shape[1])
    matrice_pct = matrice_pct.iloc[:, :max_age]

    matrice_pct.index = matrice_pct.index.astype(str)
    # Convertir l'index (Period) en string pour l'affichage

    print(f"  Cohortes analysées : {len(matrice_pct)}")
    print(f"  Période analysée   : {matrice_pct.shape[1]} mois")
    print("\nMatrice de rétention (%) :")
    print(matrice_pct.to_string())

    # ── Visualisation heatmap ─────────────────────────────────────
    fig, ax = plt.subplots(figsize=(14, 8))

    sns.heatmap(
        matrice_pct.fillna(0),
        # fillna(0) : remplacer NaN par 0 pour les cellules vides
        annot=True,
        fmt='.1f',
        cmap='YlGnBu',
        # YlGnBu : jaune -> vert -> bleu (0% -> 100%)
        vmin=0, vmax=100,
        # Plage fixe 0-100%
        linewidths=0.5,
        linecolor='white',
        ax=ax,
        annot_kws={'fontsize': 8},
        cbar_kws={'label': 'Taux de rétention (%)'}
    )

    ax.set_title("Matrice de Rétention par Cohorte\n(% de clients revenant chaque mois)",
                  fontsize=13, fontweight='bold')
    ax.set_xlabel("Mois après l'acquisition (M+0 = mois d'acquisition)")
    ax.set_ylabel("Cohorte (mois du premier achat)")

    plt.tight_layout()

    # Insight automatique
    if matrice_pct.shape[1] > 1:
        retention_m1 = matrice_pct.iloc[:, 1].mean()
        print(f"\n  Rétention moyenne M+1 : {retention_m1:.1f}%")
        if retention_m1 > 40:
            print("  [OK] Bonne rétention (> 40%)")
        elif retention_m1 > 20:
            print("  [ATTENTION]  Rétention acceptable (20-40%)")
        else:
            print("  [X] Faible rétention (< 20%) — Action corrective nécessaire")

    return matrice_pct, fig


# ════════════════════════════════════════════════════════════════════
# SECTION E : COMPARAISONS STATISTIQUES ENTRE GROUPES
# ════════════════════════════════════════════════════════════════════

def comparer_groupes_statistiquement(df: pd.DataFrame,
                                      variable: str,
                                      groupe_col: str) -> dict:
    """
    Comparaison statistique de 'variable' entre les groupes de 'groupe_col'.
    Choisit automatiquement le test approprié (paramétrique vs non-paramétrique).

    Workflow :
    1. Tester la normalité de chaque groupe (Shapiro-Wilk)
    2. Si tous normaux -> ANOVA (3+ groupes) ou t-test (2 groupes)
    3. Sinon -> Kruskal-Wallis (3+ groupes) ou Mann-Whitney (2 groupes)
    4. Si résultat significatif -> post-hoc pairwise
    """
    print(f"\n[STATS] Comparaison de '{variable}' entre les '{groupe_col}'...")

    # Extraire les groupes
    groupes = {
        nom: groupe[variable].dropna().values
        for nom, groupe in df.groupby(groupe_col)
    }

    nb_groupes = len(groupes)
    alpha = 0.05

    # ── Test de normalité (Shapiro-Wilk) ──────────────────────────
    tous_normaux = True
    print("\n  Tests de normalité (Shapiro-Wilk) :")
    for nom, valeurs in groupes.items():
        sample = valeurs[:500] if len(valeurs) > 500 else valeurs
        _, p_norm = stats.shapiro(sample)
        est_normal = p_norm >= alpha
        if not est_normal:
            tous_normaux = False
        print(f"    {nom:15s} : p={p_norm:.4f} -> {'[OK] Normal' if est_normal else '[X] Non-normal'}")

    # ── Choisir et appliquer le test ──────────────────────────────
    if nb_groupes == 2:
        noms = list(groupes.keys())
        g1, g2 = groupes[noms[0]], groupes[noms[1]]

        if tous_normaux:
            # T-test de Student (deux groupes indépendants, normaux)
            t, p = stats.ttest_ind(g1, g2)
            test_nom = "t-test de Student"
        else:
            # Mann-Whitney U (deux groupes, non paramétrique)
            t, p = stats.mannwhitneyu(g1, g2, alternative='two-sided')
            test_nom = "Mann-Whitney U"

        statistique, p_val = t, p

    else:
        # 3 groupes ou plus
        if tous_normaux:
            # ANOVA à un facteur
            statistique, p_val = stats.f_oneway(*groupes.values())
            test_nom = "ANOVA (F)"
        else:
            # Kruskal-Wallis (ANOVA non paramétrique)
            statistique, p_val = stats.kruskal(*groupes.values())
            test_nom = "Kruskal-Wallis"

    print(f"\n  Test utilisé : {test_nom}")
    print(f"  Statistique  : {statistique:.4f}")
    print(f"  p-value      : {p_val:.4f}")
    print(f"  Résultat     : {'[OK] SIGNIFICATIF (p<0.05)' if p_val < alpha else '[X] Non significatif (p≥0.05)'}")

    # ── Statistiques descriptives par groupe ──────────────────────
    print("\n  Statistiques par groupe :")
    for nom, valeurs in groupes.items():
        print(f"    {nom:20s} : n={len(valeurs):4d}, "
              f"moy={np.mean(valeurs):10.2f}, "
              f"méd={np.median(valeurs):10.2f}, "
              f"σ={np.std(valeurs):8.2f}")

    # ── Taille d'effet (Eta-carré pour ANOVA) ────────────────────
    if nb_groupes >= 3 and tous_normaux:
        # Eta-carré : proportion de variance expliquée par le groupe
        total = np.concatenate(list(groupes.values()))
        moyenne_totale = total.mean()
        ss_between = sum(
            len(g) * (np.mean(g) - moyenne_totale) ** 2
            for g in groupes.values()
        )
        ss_total = sum((x - moyenne_totale) ** 2 for x in total)
        eta_carre = ss_between / ss_total if ss_total != 0 else 0
        print(f"\n  Eta-carré (η²) : {eta_carre:.4f}")
        if eta_carre < 0.01:
            interpretation = "Effet négligeable"
        elif eta_carre < 0.06:
            interpretation = "Petit effet"
        elif eta_carre < 0.14:
            interpretation = "Effet moyen"
        else:
            interpretation = "Grand effet"
        print(f"  Interprétation : {interpretation}")

    return {
        'test': test_nom,
        'statistique': statistique,
        'p_value': p_val,
        'significatif': p_val < alpha,
        'groupes': {k: {'n': len(v), 'mean': np.mean(v), 'median': np.median(v)}
                    for k, v in groupes.items()}
    }


# ════════════════════════════════════════════════════════════════════
# ORCHESTRATION : APPELER TOUTES LES ANALYSES AVANCÉES
# ════════════════════════════════════════════════════════════════════

def analyser_avance_complet(df: pd.DataFrame) -> dict:
    """
    Lance toutes les analyses avancées et retourne les résultats.
    À appeler depuis main.py.
    """
    print("\n" + "="*65)
    print("ANALYSE AVANCÉE COMPLÈTE — ShopSmart SARL")
    print("="*65)

    resultats = {}

    # A. GroupBy avancé
    resultats['rapport_multi'] = analyse_groupby_multi_niveaux(df)
    resultats['df_enrichi']    = ajouter_colonnes_groupby(df)
    resultats['df_filtre']     = filtrer_groupes_par_critere(df, seuil_ca_min=50000)

    # B. Pivots et crosstab
    resultats['pivots']   = creer_pivot_tables(df)
    resultats['crosstab'] = analyser_crosstab(df)
    stack_unstack_melt_demo(df)

    # C. RFM
    rfm = calculer_rfm(df)
    resultats['rfm'] = rfm
    fig_rfm = visualiser_rfm(rfm)
    fig_rfm.savefig("reports/figures/rfm_dashboard.png", dpi=150, bbox_inches='tight')
    plt.close(fig_rfm)
    print("[RFM] Dashboard sauvegardé : reports/figures/rfm_dashboard.png")

    # D. Cohortes
    matrice_coh, fig_coh = analyser_cohortes(df)
    resultats['cohortes'] = matrice_coh
    fig_coh.savefig("reports/figures/cohortes_retention.png", dpi=150, bbox_inches='tight')
    plt.close(fig_coh)
    print("[COHORTES] Heatmap sauvegardée : reports/figures/cohortes_retention.png")

    # E. Tests statistiques
    print("\n[STATS] Comparaison CA par catégorie :")
    resultats['test_categorie'] = comparer_groupes_statistiquement(
        df, variable='total_amount', groupe_col='category'
    )

    print("\n[STATS] Comparaison CA par région :")
    resultats['test_region'] = comparer_groupes_statistiquement(
        df, variable='total_amount', groupe_col='region'
    )

    print("\n[ANALYSE AVANCÉE] Terminée [OK]")
    return resultats
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  EXPLICATION LIGNE PAR LIGNE (POINTS CLÉS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

COMPRENDRE NAMED AGGREGATION :

  # Syntaxe pandas 0.25+ (moderne)
  df.groupby('cat').agg(
      nom_colonne_résultat = ('colonne_source', 'fonction')
  )

  # Équivalent ancienne syntaxe
  df.groupby('cat').agg({'colonne_source': 'fonction'}).rename(
      columns={'colonne_source': 'nom_colonne_résultat'}
  )

  Avantage : lisibilité — on voit immédiatement le nom du résultat

COMPRENDRE TRANSFORM VS AGG :

  df.groupby('cat').agg(...)
  -> Retourne N_groupes lignes (une par catégorie)
  -> Ne peut PAS être directement réaffecté à df

  df.groupby('cat')['col'].transform(...)
  -> Retourne N lignes (même que df)
  -> Peut DIRECTEMENT être réaffecté : df['new_col'] = ...

  Mnémotechnique :
  AGG       = AGRÈGE (condense les données)
  TRANSFORM = TRANSFORME (garde la même taille)

COMPRENDRE PD.QCUT :

  pd.qcut(valeurs, q=5, labels=[1,2,3,4,5])
  -> Découpe en 5 groupes de taille ÉGALE (quintiles)
  -> Le 1er groupe (score 1) contient les 20% les plus bas
  -> Le 5ème groupe (score 5) contient les 20% les plus hauts

  Différence avec pd.cut :
  pd.cut  : intervalles de largeur FIXE (ex: 0-100, 100-200...)
  pd.qcut : intervalles contenant le MÊME NOMBRE de valeurs

  Pour RFM, qcut est préférable car les distributions sont souvent
  asymétriques — pd.cut laisserait certains groupes vides.

COMPRENDRE .MAP(DICT) :

  premier_achat = df.groupby('customer_id')['date'].min()
  # Résultat : Series avec customer_id comme index

  df['cohorte'] = df['customer_id'].map(premier_achat)
  # Pour chaque customer_id dans df,
  # retrouver la valeur correspondante dans premier_achat

  C'est équivalent à un LEFT JOIN sur customer_id,
  mais beaucoup plus rapide car pas de join.

COMPRENDRE DIVIDE(AXE=0) :

  matrice_pct = matrice.divide(taille_cohorte, axis=0)

  Sans axis=0, pandas essaierait de diviser colonne par colonne.
  Avec axis=0, il divise LIGNE PAR LIGNE.

  Chaque ligne est divisée par la taille de SA cohorte.
  Ligne "Jan 2023" -> divisée par taille_cohorte["Jan 2023"]
  Ligne "Feb 2023" -> divisée par taille_cohorte["Feb 2023"]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  ANALYSE ET INTERPRÉTATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

INSIGHTS BUSINESS ATTENDUS :

1. SEGMENTATION RFM :
   En général dans l'e-commerce :
   - Champions       : 5-10% des clients, 30-40% du CA
   - Clients Perdus  : 20-30% des clients, < 5% du CA
   ACTION : Concentrer les efforts sur les "À Risque" (anciens bons clients)
   car ils sont récupérables avec un bon programme de réactivation.

2. RÉTENTION DES COHORTES :
   - Si M+1 > 40% : excellent (rétention forte)
   - Si M+1 = 20-40% : acceptable pour l'e-commerce
   - Si M+1 < 20% : problème — les clients achètent puis disparaissent
   ACTION : Mettre en place un email de follow-up à J+7 après le premier achat.

3. COMPARAISONS STATISTIQUES :
   Si ANOVA (CA par catégorie) est significatif (p < 0.05) :
   Les catégories ne sont PAS équivalentes en termes de CA moyen.
   Cette différence n'est pas due au hasard.
   ACTION : Adapter la politique de prix par catégorie.

4. COMPORTEMENT RÉGIONAL (CHI²) :
   Si significatif : certaines régions préfèrent certaines catégories.
   ACTION : Personnaliser les newsletters par région.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8⃣  BONNES PRATIQUES PROFESSIONNELLES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÈGLE 1 : Toujours documenter les définitions métier
  # Qu'est-ce que la "récence" pour votre business ?
  # Date dernière commande ? Dernière connexion ? Dernier clic ?
  # Ces choix IMPACTENT directement les segments obtenus.

RÈGLE 2 : Valider les tailles de groupes avant le test
  # Un t-test sur 5 vs 500 personnes n'a pas de sens
  # Règle empirique : au moins 30 observations par groupe

RÈGLE 3 : Corriger pour les tests multiples
  # Si vous faites 20 tests avec α=0.05, attendez-vous à 1 faux positif par hasard
  # Correction de Bonferroni : α_ajusté = α / nb_tests
  from statsmodels.stats.multitest import multipletests
  # multipletests(p_values, method='bonferroni')

RÈGLE 4 : Ne pas confondre significativité statistique et importance pratique
  # Avec 10 000 clients, une différence de 0.50 € peut être très significative
  # (p < 0.001) mais sans intérêt business.
  # TOUJOURS calculer la taille d'effet (Cohen's d, Eta-carré...).

RÈGLE 5 : Garder les données brutes et les segmentations séparées
  # df_raw       -> données brutes
  # df_rfm       -> métriques RFM uniquement
  # Permettre de recalculer les segments avec différents critères

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────

EX 6.1 — GroupBy basique
  Sur le dataset ShopSmart :
  a) Calculez le CA total, moyen et le nombre de commandes par RÉGION.
  b) Quel région génère le plus de CA ?
  c) Quelle région a le panier moyen le plus élevé ?

EX 6.2 — Transform
  Ajoutez une colonne 'ca_pct_region' indiquant, pour chaque transaction,
  quel % elle représente du CA total de sa région.
  Vérification : la somme par région doit être égale à 100.

EX 6.3 — Pivot simple
  Créez un pivot table du NOMBRE DE COMMANDES par :
  - Lignes : mois
  - Colonnes : mode de paiement

── NIVEAU INTERMÉDIAIRE ───────────────────────────────────────────

EX 6.4 — RFM personnalisé
  Modifiez la fonction calculer_rfm() pour ajouter un score V (Valeur)
  basé sur la valeur du panier moyen (total_amount / nb_commandes).
  Le segment "RFMV Champions" nécessite R≥4, F≥4, M≥4, V≥4.

EX 6.5 — Analyse saisonnalité
  Créez un pivot table pour détecter la saisonnalité :
  - Lignes : jour de la semaine (Lundi à Dimanche)
  - Colonnes : heure de la journée (si disponible) ou catégorie
  - Valeurs : CA moyen
  Quel jour et quelle heure les ventes sont-elles les plus élevées ?

EX 6.6 — Comparaison statistique
  Testez si le taux de retour est significativement différent entre
  les clients fumeurs et non-fumeurs (si votre dataset a cette info)
  ou entre les différentes méthodes de paiement.
  Utilisez le test approprié (paramétrique ou non).

── NIVEAU AVANCÉ ──────────────────────────────────────────────────

EX 6.7 — Analyse panier (Market Basket Analysis simplifiée)
  Trouvez les paires de produits souvent achetés ensemble.
  Pour chaque order_id, regrouper les product_name.
  Calculer la fréquence de chaque paire.
  Afficher les 10 paires les plus fréquentes.
  Indice : utiliser itertools.combinations

EX 6.8 — Prédire le churn
  Basé sur la segmentation RFM, créez une variable binaire 'at_churn_risk'
  (= 1 si le client est dans le segment "À Risque" ou "Clients Perdus").
  Calculez le taux de churn prédit dans le prochain mois.
  Visualisez les caractéristiques des clients à risque vs les autres.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉ ULTRA DÉTAILLÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── CORRIGÉ EX 6.7 (Market Basket Analysis simplifiée) ────────────

```python
import pandas as pd
import numpy as np
from itertools import combinations
from collections import Counter

def analyser_paniers(df: pd.DataFrame, top_n: int = 10) -> pd.DataFrame:
    """
    Trouve les produits souvent achetés ensemble.
    Approche : Pour chaque commande, extraire toutes les paires de produits.
    """

    # Étape 1 : Regrouper les produits par commande
    paniers = df.groupby('order_id')['product_name'].apply(list)
    # .apply(list) : transformer la Series en liste pour chaque commande
    # Résultat : Series dont chaque valeur est une liste de produits
    # Ex: order_id 1001 -> ['Laptop Pro', 'Souris Gaming']

    print(f"Nb commandes analysées : {len(paniers)}")
    print(f"Commandes multi-produits : {(paniers.apply(len) > 1).sum()}")

    # Étape 2 : Générer toutes les paires
    compteur_paires = Counter()

    for produits in paniers:
        # Pour chaque commande, générer toutes les paires uniques
        if len(produits) >= 2:
            for paire in combinations(sorted(set(produits)), 2):
                # combinations(iterable, r) : toutes les combinaisons de taille r
                # sorted(set(produits)) : dédupliquer et trier pour cohérence
                # paire : tuple (produit_a, produit_b)
                compteur_paires[paire] += 1

    # Étape 3 : Créer un DataFrame des résultats
    df_paires = pd.DataFrame(
        [(p[0], p[1], count) for p, count in compteur_paires.most_common(top_n)],
        columns=['Produit_A', 'Produit_B', 'Co_occurrences']
    )
    # most_common(top_n) : les top_n paires les plus fréquentes

    # Étape 4 : Calculer le support (fréquence relative)
    nb_commandes_total = len(paniers)
    df_paires['Support_pct'] = (df_paires['Co_occurrences'] / nb_commandes_total * 100).round(2)

    print(f"\nTop {top_n} paires de produits co-achetés :")
    print(df_paires.to_string(index=False))

    return df_paires

# Pour tester (avec le dataset ShopSmart) :
# paires = analyser_paniers(df_propre, top_n=10)
```

── CORRIGÉ EX 6.4 (RFM avec score V) ────────────────────────────

```python
import pandas as pd
import numpy as np
from typing import Optional

def calculer_rfmv(df: pd.DataFrame,
                   date_reference: Optional[str] = None) -> pd.DataFrame:
    """
    Segmentation RFMV : Récence, Fréquence, Montant, Valeur du panier.
    Extension de la segmentation RFM standard.
    """
    df_c = df.copy()
    df_c['date'] = pd.to_datetime(df_c['date'])

    date_ref = df_c['date'].max() + pd.Timedelta(days=1) if date_reference is None \
        else pd.to_datetime(date_reference)

    # Métriques de base
    rfmv = df_c.groupby('customer_id').agg(
        derniere_cmd  = ('date', 'max'),
        nb_commandes  = ('order_id', 'nunique'),
        ca_total      = ('total_amount', 'sum'),
        ca_par_cmd    = ('total_amount', 'mean')
        # ca_par_cmd = panier moyen du client (nouvelle métrique V)
    )

    rfmv['recence']  = (date_ref - rfmv['derniere_cmd']).dt.days
    rfmv = rfmv.rename(columns={'nb_commandes': 'frequence', 'ca_total': 'montant'})
    rfmv = rfmv[['recence', 'frequence', 'montant', 'ca_par_cmd']]

    # Scoring 1-5
    rfmv['score_R'] = pd.qcut(rfmv['recence'],                    q=5, labels=[5,4,3,2,1])
    rfmv['score_F'] = pd.qcut(rfmv['frequence'].rank(method='first'), q=5, labels=[1,2,3,4,5])
    rfmv['score_M'] = pd.qcut(rfmv['montant'].rank(method='first'),   q=5, labels=[1,2,3,4,5])
    rfmv['score_V'] = pd.qcut(rfmv['ca_par_cmd'].rank(method='first'),q=5, labels=[1,2,3,4,5])
    # score_V : valeur du panier moyen -> 5 = gros paniers

    # Convertir en int
    for col in ['score_R', 'score_F', 'score_M', 'score_V']:
        rfmv[col] = rfmv[col].astype(int)

    # Segmentation RFMV
    def segment_rfmv(row):
        r, f, m, v = row['score_R'], row['score_F'], row['score_M'], row['score_V']
        if r >= 4 and f >= 4 and m >= 4 and v >= 4:
            return 'Champions RFMV'
        elif r >= 4 and v >= 4:
            return 'Haute Valeur (récents, gros paniers)'
        elif f >= 4 and v <= 2:
            return 'Acheteurs Fréquents Petits Paniers'
        elif r >= 4 and f <= 2:
            return 'Nouveaux Acheteurs'
        elif r <= 2 and m >= 4:
            return 'Anciens Gros Clients (à réactiver)'
        else:
            return 'Standard'

    rfmv['segment_rfmv'] = rfmv.apply(segment_rfmv, axis=1)

    print("Distribution des segments RFMV :")
    print(rfmv['segment_rfmv'].value_counts())

    print("\nCA moyen par segment :")
    print(rfmv.groupby('segment_rfmv')[['montant', 'ca_par_cmd']].mean().round(0))

    return rfmv

# Utilisation :
# rfmv = calculer_rfmv(df_propre)
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[NOTE] RÉCAPITULATIF PARTIE 6
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Ce que vous avez appris :

[OK] GroupBy avancé : agg() / transform() / filter() / apply()
[OK] Named Aggregation (syntaxe pandas 0.25+)
[OK] Pivot tables multi-niveaux et multi-valeurs
[OK] Crosstab avec normalisation et test chi-carré
[OK] Stack / unstack / melt : transformations de structure
[OK] Segmentation RFM complète (scoring, segments, dashboard)
[OK] Extension RFMV (ajout de la valeur du panier)
[OK] Analyse de cohortes et matrice de rétention
[OK] Tests statistiques automatisés (paramétrique vs non-paramétrique)
[OK] Market Basket Analysis avec itertools.combinations

Fichiers créés :
  reports/figures/rfm_dashboard.png        <- Dashboard segmentation RFM
  reports/figures/cohortes_retention.png   <- Heatmap de rétention

Prochaine étape : PARTIE 7 — Cas Business Complet
  (Analyse intégrée, recommandations, pipeline automatisé)

================================================================================
FIN PARTIE 6
================================================================================

================================================================================
[GRAPHIQUE] DATAINSIGHT PRO — PARTIE 7
CAS BUSINESS RÉEL : ANALYSE CLIENTS & ANALYSE VENTES
================================================================================
Projet : ShopSmart SARL — Rapport d'analyse complète pour le comité de direction
Fichier : main.py (orchestration) + notebooks/exploration.ipynb
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER RÉEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Entreprise fictive : ShopSmart SARL
Mission            : Comité de direction trimestriel

Marc Dumont convoque une réunion de direction avec :
  - Fatou Diallo (Marketing)
  - Ousmane Sow (Commercial)
  - Aminata Ndiaye (Opérations)
  - Ibrahima Ba (Finance)

Il vous mandate pour produire un rapport d'analyse complet répondant
à 4 questions stratégiques :

Q1 (Marketing) : "Qui sont nos clients les plus rentables et comment
                  les fidéliser ?"

Q2 (Commercial) : "Quels produits surperforment/sous-performent ?
                   Comment optimiser notre catalogue ?"

Q3 (Opérations) : "Quel est le coût des retours ? Comment les réduire ?"

Q4 (Finance)    : "Quelle est la rentabilité réelle par segment ?
                   Quelles sont nos prévisions pour Q1 N+1 ?"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Compétences développées :
  [OK] Intégrer toutes les compétences des parties 1-6 dans un projet cohérent
  [OK] Structurer une analyse autour de questions business réelles
  [OK] Calculer des KPIs business avancés (LTV, ARPU, taux de churn...)
  [OK] Construire un pipeline d'analyse automatisé
  [OK] Rédiger des insights actionnables (pas juste des stats)
  [OK] Préparer un forecast simple de séries temporelles

Concepts du guide couverts :
  -> Chapitres 3, 15-21 : Pandas pour l'analyse business
  -> Chapitres 26-29 : EDA et insights
  -> Chapitres 33-35 : Tests statistiques appliqués
  -> Chapitres 36-38 : Introduction ML (pour la prévision)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  THÉORIE APPLIQUÉE : KPIs BUSINESS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

──────────────────────────────────────────
KPIs CLIENTS (Customer Metrics)
──────────────────────────────────────────

  LTV — Customer Lifetime Value
  ─────────────────────────────
  Définition : Valeur totale qu'un client génère sur toute sa vie client.

  Formule simplifiée :
    LTV = Panier moyen × Fréquence achats/an × Durée relation (années)

  Formule avancée :
    LTV = (CA moyen × Marge brute) / (1 + Taux d'actualisation - Taux de rétention)

  Pourquoi c'est important :
    "Si la LTV d'un client est 500€ et que l'acquisition coûte 50€,
     le ROI est de 10x. Si elle coûte 600€, on perd de l'argent."

  ARPU — Average Revenue Per User (Revenu Moyen par Utilisateur)
  ──────────────────────────────────────────────────────────────
  ARPU = CA Total / Nb clients actifs sur la période

  ARPU mensuel vs annuel : toujours préciser la période.

  CAC — Customer Acquisition Cost (Coût d'Acquisition Client)
  ──────────────────────────────────────────────────────────
  CAC = Dépenses marketing totales / Nb nouveaux clients

  Ratio LTV/CAC cible : > 3 (LTV doit être au moins 3× le CAC)

  Churn Rate (Taux d'Attrition)
  ─────────────────────────────
  Churn = Nb clients perdus / Nb clients en début de période

  Un client est "perdu" si inactif depuis > X jours (à définir selon le business).
  E-commerce : souvent > 180 jours = churné.

──────────────────────────────────────────
KPIs PRODUITS (Product Metrics)
──────────────────────────────────────────

  ABC Analysis
  ─────────────
  Classifie les produits en 3 catégories selon le CA :

  A : Top 20% des produits -> génèrent 80% du CA (Pareto)
  B : 30% des produits    -> génèrent 15% du CA
  C : 50% des produits    -> génèrent 5% du CA

  ACTION :
  A -> Protéger le stock, négocier les meilleurs prix fournisseurs
  B -> Surveiller, optimiser la marge
  C -> Envisager de les retirer du catalogue

  Taux de Conversion par Catégorie
  ──────────────────────────────────
  = Nb achats / Nb vues produit (si données de trafic disponibles)

  Net Margin par Produit
  ──────────────────────
  = (Prix vente - Prix achat - Coût retour) / Prix vente × 100

──────────────────────────────────────────
KPIs RETOURS (Return Metrics)
──────────────────────────────────────────

  Taux de Retour = Nb produits retournés / Nb produits vendus
  Coût moyen d'un retour = Frais de port + Handling + Perte de valeur

  En e-commerce, un retour coûte en moyenne 20-30€ (traitement + port).

  L'analyse des retours par :
  - Catégorie : les vêtements ont souvent 15-20%, l'électronique 5-8%
  - Mode de paiement : les paiements à la livraison ont plus de retours
  - Région : peut indiquer des problèmes logistiques spécifiques

──────────────────────────────────────────
PRÉVISION SIMPLE (Forecasting)
──────────────────────────────────────────

  Méthode la plus simple : Régression linéaire sur le temps
  y = a × t + b
  où t = indice temporel (0, 1, 2, ...) et y = CA mensuel

  Avantages : simple, interprétable
  Limites   : ne capture pas la saisonnalité

  Méthode intermédiaire : Décomposition STL
  Décompose la série en : Tendance + Saisonnalité + Résidu

  Pour des prévisions sérieuses : utiliser Prophet (Facebook) ou ARIMA

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  IMPLÉMENTATION COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CODE : Analyse Business Complète
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
"""
business_analysis.py
─────────────────────
Module d'analyse business pour le rapport du comité de direction.
Répond aux 4 questions stratégiques de ShopSmart.
"""

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
from scipy import stats
from sklearn.linear_model import LinearRegression
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')

# ════════════════════════════════════════════════════════════════════
# Q1 — ANALYSE CLIENTS : QUI SONT NOS MEILLEURS CLIENTS ?
# ════════════════════════════════════════════════════════════════════

def analyser_clients(df: pd.DataFrame) -> dict:
    """
    Analyse complète de la base clients.
    Répond à la question de Fatou (Marketing).
    """
    print("\n" + "="*65)
    print("Q1 — ANALYSE CLIENTS")
    print("="*65)

    resultats = {}

    # ── 1.1 KPIs Clients de base ──────────────────────────────────
    nb_clients = df['customer_id'].nunique()
    # nunique() : nombre de valeurs distinctes (clients uniques)

    df['date'] = pd.to_datetime(df['date'])

    # Clients actifs = ont commandé dans les 90 derniers jours
    date_max = df['date'].max()
    date_seuil = date_max - pd.Timedelta(days=90)
    clients_actifs = df[df['date'] >= date_seuil]['customer_id'].nunique()

    # ARPU = CA total / nb clients
    ca_total = df['total_amount'].sum()
    arpu = ca_total / nb_clients

    # Panier moyen = CA total / nb commandes
    nb_commandes = df['order_id'].nunique()
    panier_moyen = ca_total / nb_commandes

    # Fréquence moyenne d'achat par client
    frequence_moy = nb_commandes / nb_clients

    print(f"\n  KPIS CLIENTS :")
    print(f"  Nb clients total    : {nb_clients:,}")
    print(f"  Nb clients actifs   : {clients_actifs:,} ({clients_actifs/nb_clients*100:.1f}%)")
    print(f"  Taux d'activité     : {clients_actifs/nb_clients*100:.1f}%")
    print(f"  ARPU                : {arpu:.2f} €/client")
    print(f"  Panier moyen        : {panier_moyen:.2f} €")
    print(f"  Fréquence moy./client: {frequence_moy:.1f} commandes")

    resultats['kpis'] = {
        'nb_clients': nb_clients,
        'clients_actifs': clients_actifs,
        'arpu': arpu,
        'panier_moyen': panier_moyen,
        'frequence_moy': frequence_moy
    }

    # ── 1.2 LTV Simplifiée ────────────────────────────────────────
    # Durée d'observation en années
    duree_mois = (date_max - df['date'].min()).days / 30
    duree_annees = duree_mois / 12

    ltv_simple = arpu * (frequence_moy / duree_annees) * 2
    # LTV estimée sur 2 ans
    # arpu : revenu moyen par client sur la période
    # frequence_moy / duree_annees : fréquence annualisée
    # × 2 : projection sur 2 ans

    print(f"\n  LTV estimée (2 ans) : {ltv_simple:.2f} €/client")
    resultats['ltv_simple'] = ltv_simple

    # ── 1.3 Distribution des clients par valeur ───────────────────
    ca_par_client = df.groupby('customer_id')['total_amount'].sum()
    # Classer par décile
    ca_par_client_sorted = ca_par_client.sort_values(ascending=False)
    n = len(ca_par_client_sorted)

    top10pct_clients  = ca_par_client_sorted.head(int(n * 0.10))
    top10pct_ca       = top10pct_clients.sum() / ca_total * 100

    top20pct_clients  = ca_par_client_sorted.head(int(n * 0.20))
    top20pct_ca       = top20pct_clients.sum() / ca_total * 100

    print(f"\n  CONCENTRATION DES VENTES :")
    print(f"  Top 10% des clients génèrent {top10pct_ca:.1f}% du CA")
    print(f"  Top 20% des clients génèrent {top20pct_ca:.1f}% du CA")
    # Si ~80% -> Règle de Pareto vérifiée

    # ── 1.4 Courbe de Lorenz ─────────────────────────────────────
    # La courbe de Lorenz visualise l'inégalité de distribution
    ca_cumule = ca_par_client_sorted[::-1].cumsum() / ca_total
    # [::-1] : trier par ordre croissant pour la courbe de Lorenz
    # cumsum() : somme cumulée
    # / ca_total : proportion cumulée

    pop_cumule = np.arange(1, n + 1) / n
    # Proportion cumulée de clients

    # Coefficient de Gini
    gini = 1 - 2 * np.trapz(ca_cumule[::-1], pop_cumule)
    # np.trapz : intégration numérique (trapèze)
    # Gini = 1 - 2 × Aire sous la courbe de Lorenz
    # Gini = 0 : parfaite égalité
    # Gini = 1 : parfaite inégalité

    print(f"  Coefficient de Gini : {gini:.3f}")
    print(f"  (0=égalité parfaite, 1=inégalité totale)")
    resultats['gini'] = gini

    # ── 1.5 Segmentation par âge × catégorie ─────────────────────
    df['tranche_age'] = pd.cut(
        df['customer_age'].fillna(df['customer_age'].median()),
        bins=[18, 30, 45, 65, 100],
        labels=['18-30', '31-45', '46-65', '65+']
    )

    ca_age_cat = pd.crosstab(
        df['tranche_age'],
        df['category'],
        values=df['total_amount'],
        aggfunc='sum',
        normalize='index'
        # normalize='index' : % par tranche d'âge
    ).round(3) * 100

    print("\n  CA par tranche d'âge × catégorie (%) :")
    print(ca_age_cat)
    resultats['ca_age_cat'] = ca_age_cat

    # ── 1.6 Taux de churn approximatif ───────────────────────────
    # Clients "perdus" : inactifs depuis > 180 jours
    date_seuil_churn = date_max - pd.Timedelta(days=180)
    derniere_cmd_par_client = df.groupby('customer_id')['date'].max()

    clients_churnes = (derniere_cmd_par_client < date_seuil_churn).sum()
    taux_churn = clients_churnes / nb_clients * 100

    print(f"\n  TAUX DE CHURN :")
    print(f"  Clients inactifs > 180j : {clients_churnes:,} ({taux_churn:.1f}%)")
    resultats['taux_churn'] = taux_churn

    return resultats


# ════════════════════════════════════════════════════════════════════
# Q2 — ANALYSE PRODUITS : QUELS PRODUITS SURPERFORMENT ?
# ════════════════════════════════════════════════════════════════════

def analyser_produits(df: pd.DataFrame) -> dict:
    """
    Analyse ABC des produits + détection des sous-performants.
    Répond à la question d'Ousmane (Commercial).
    """
    print("\n" + "="*65)
    print("Q2 — ANALYSE PRODUITS")
    print("="*65)

    resultats = {}

    # ── 2.1 Métriques par produit ─────────────────────────────────
    produits = df.groupby('product_name').agg(
        ca_total      = ('total_amount', 'sum'),
        nb_ventes     = ('quantity', 'sum'),
        nb_commandes  = ('order_id', 'nunique'),
        prix_moyen    = ('price', 'mean'),
        taux_retour   = ('is_returned', 'mean')
    ).round(2)

    produits['ca_par_vente'] = produits['ca_total'] / produits['nb_ventes']
    produits = produits.sort_values('ca_total', ascending=False)

    # ── 2.2 Classification ABC ────────────────────────────────────
    ca_cumulatif = produits['ca_total'].cumsum()
    ca_total_produits = produits['ca_total'].sum()
    pct_ca_cumulatif = ca_cumulatif / ca_total_produits * 100
    # pct_ca_cumulatif : pourcentage cumulé du CA

    def classification_abc(pct_cumul):
        """
        Retourne A, B ou C selon le pourcentage cumulatif du CA.
        A : premiers produits qui font 80% du CA
        B : produits de 80% à 95% du CA
        C : reste (5% final)
        """
        if pct_cumul <= 80:
            return 'A'
        elif pct_cumul <= 95:
            return 'B'
        else:
            return 'C'

    produits['classe_ABC'] = pct_ca_cumulatif.apply(classification_abc)

    # Résumé ABC
    resume_abc = produits.groupby('classe_ABC').agg(
        nb_produits   = ('ca_total', 'count'),
        ca_total      = ('ca_total', 'sum'),
        taux_retour_m = ('taux_retour', 'mean')
    )
    resume_abc['pct_nb_produits'] = resume_abc['nb_produits'] / len(produits) * 100
    resume_abc['pct_ca']          = resume_abc['ca_total'] / ca_total_produits * 100

    print("\n  ANALYSE ABC :")
    print(resume_abc[['nb_produits', 'pct_nb_produits', 'pct_ca', 'taux_retour_m']].round(1))

    resultats['produits_abc'] = produits
    resultats['resume_abc']   = resume_abc

    # ── 2.3 Top 10 et Flop 10 ────────────────────────────────────
    top10  = produits.head(10)[['ca_total', 'nb_ventes', 'prix_moyen', 'taux_retour']]
    flop10 = produits.tail(10)[['ca_total', 'nb_ventes', 'prix_moyen', 'taux_retour']]

    print("\n  TOP 10 PRODUITS (par CA) :")
    print(top10.to_string())

    print("\n  FLOP 10 PRODUITS (par CA) :")
    print(flop10.to_string())

    resultats['top10']  = top10
    resultats['flop10'] = flop10

    # ── 2.4 Produits à fort taux de retour ───────────────────────
    seuil_retour = 0.15
    # Alerter sur les produits avec > 15% de retour
    produits_retour_eleve = produits[
        (produits['taux_retour'] > seuil_retour) &
        (produits['nb_ventes'] > 20)
        # Ne s'intéresser qu'aux produits avec suffisamment de ventes
    ].sort_values('taux_retour', ascending=False)

    print(f"\n  PRODUITS À FORT TAUX DE RETOUR (> {seuil_retour*100:.0f}%, min 20 ventes) :")
    if len(produits_retour_eleve) > 0:
        print(produits_retour_eleve[['ca_total', 'nb_ventes', 'taux_retour']].head(10))
        print(f"\n  [ATTENTION]  {len(produits_retour_eleve)} produit(s) nécessitent une investigation")
    else:
        print("  [OK] Aucun produit avec un taux de retour excessif")

    resultats['produits_retour_eleve'] = produits_retour_eleve

    # ── 2.5 Visualisation ABC ─────────────────────────────────────
    fig, axes = plt.subplots(1, 2, figsize=(14, 6))

    # Courbe de Pareto (Lorenz produits)
    axes[0].bar(
        range(len(produits)),
        produits['ca_total'].values,
        color=[{'A': '#2196F3', 'B': '#4CAF50', 'C': '#9E9E9E'}[c]
               for c in produits['classe_ABC']],
        width=1.0, edgecolor='none'
    )
    ax2_pareto = axes[0].twinx()
    ax2_pareto.plot(
        range(len(produits)),
        pct_ca_cumulatif.values,
        'r-', linewidth=2, label='CA cumulatif %'
    )
    ax2_pareto.axhline(80, color='orange', linestyle='--', alpha=0.7, label='80%')
    ax2_pareto.axhline(95, color='red',    linestyle='--', alpha=0.7, label='95%')
    ax2_pareto.set_ylabel("CA Cumulatif (%)")
    ax2_pareto.legend(fontsize=8, loc='center right')
    ax2_pareto.set_ylim(0, 105)
    axes[0].set_title("Analyse ABC — Courbe de Pareto\n(Bleu=A, Vert=B, Gris=C)")
    axes[0].set_xlabel("Produits (triés par CA décroissant)")
    axes[0].set_ylabel("CA par produit (€)")

    # Répartition ABC
    from matplotlib.patches import Patch
    abc_counts = produits['classe_ABC'].value_counts().sort_index()
    abc_ca     = produits.groupby('classe_ABC')['ca_total'].sum().sort_index()

    x = np.arange(3)
    width = 0.35
    couleurs_abc = ['#2196F3', '#4CAF50', '#9E9E9E']

    bars1 = axes[1].bar(x - width/2,
        [abc_counts.get('A',0), abc_counts.get('B',0), abc_counts.get('C',0)],
        width, label='Nb produits', color=couleurs_abc, alpha=0.7
    )
    axes[1].set_xticks(x)
    axes[1].set_xticklabels(['Classe A', 'Classe B', 'Classe C'])
    axes[1].set_ylabel("Nb produits", color='steelblue')
    axes[1].tick_params(axis='y', labelcolor='steelblue')

    ax2_abc = axes[1].twinx()
    ax2_abc.plot(
        x,
        [abc_ca.get('A',0)/1000, abc_ca.get('B',0)/1000, abc_ca.get('C',0)/1000],
        'ro-', linewidth=2, markersize=8, label='CA (k€)'
    )
    ax2_abc.set_ylabel("CA (k€)", color='red')
    ax2_abc.tick_params(axis='y', labelcolor='red')
    axes[1].set_title("Répartition Nb Produits vs CA par Classe ABC")

    plt.tight_layout()
    fig.savefig("reports/figures/analyse_abc.png", dpi=150, bbox_inches='tight')
    plt.close(fig)
    print("\n  [VIZ] Graphique ABC sauvegardé")

    return resultats


# ════════════════════════════════════════════════════════════════════
# Q3 — ANALYSE DES RETOURS : COÛT ET CAUSES
# ════════════════════════════════════════════════════════════════════

def analyser_retours(df: pd.DataFrame,
                      cout_retour_fixe: float = 25.0) -> dict:
    """
    Analyse complète des retours et leur impact financier.
    Répond à la question d'Aminata (Opérations).

    Paramètres :
      cout_retour_fixe : Coût fixe estimé d'un retour (port + traitement) en €
    """
    print("\n" + "="*65)
    print("Q3 — ANALYSE DES RETOURS")
    print("="*65)

    resultats = {}

    retours = df[df['is_returned'] == True]
    # Filtrer uniquement les commandes retournées

    nb_retours = len(retours)
    taux_retour_global = nb_retours / len(df) * 100

    cout_total_retours = nb_retours * cout_retour_fixe + retours['total_amount'].sum()
    # Coût total = coût fixe de traitement × nb retours + valeur des articles rendus
    # (les articles retournés représentent une perte de CA brut)

    print(f"\n  IMPACT FINANCIER DES RETOURS :")
    print(f"  Nb retours           : {nb_retours:,} ({taux_retour_global:.1f}%)")
    print(f"  Valeur articles rendus: {retours['total_amount'].sum():>12,.0f} €")
    print(f"  Coût traitement       : {nb_retours * cout_retour_fixe:>12,.0f} €")
    print(f"  COÛT TOTAL RETOURS    : {cout_total_retours:>12,.0f} €")
    print(f"  Impact sur CA         : {retours['total_amount'].sum()/df['total_amount'].sum()*100:.1f}%")

    resultats['kpis_retours'] = {
        'nb_retours': nb_retours,
        'taux_retour': taux_retour_global,
        'cout_total': cout_total_retours
    }

    # ── 3.1 Taux de retour par catégorie ─────────────────────────
    retours_cat = df.groupby('category').agg(
        nb_total   = ('order_id', 'count'),
        nb_retours = ('is_returned', 'sum'),
        ca_retours = ('total_amount', lambda x: x[df.loc[x.index, 'is_returned']].sum()
                       if len(x) > 0 else 0)
    )
    retours_cat['taux_retour_pct'] = retours_cat['nb_retours'] / retours_cat['nb_total'] * 100

    print("\n  TAUX DE RETOUR PAR CATÉGORIE :")
    print(retours_cat[['nb_total', 'nb_retours', 'taux_retour_pct']].round(1).to_string())

    resultats['retours_categorie'] = retours_cat

    # ── 3.2 Taux de retour par mode de paiement ──────────────────
    retours_paiement = df.groupby('payment_method').agg(
        nb_total   = ('order_id', 'count'),
        nb_retours = ('is_returned', 'sum')
    )
    retours_paiement['taux_pct'] = (
        retours_paiement['nb_retours'] / retours_paiement['nb_total'] * 100
    ).round(1)

    print("\n  TAUX DE RETOUR PAR MODE DE PAIEMENT :")
    print(retours_paiement.sort_values('taux_pct', ascending=False).to_string())

    # Test chi-carré : le mode de paiement influence-t-il les retours ?
    ct = pd.crosstab(df['payment_method'], df['is_returned'])
    chi2, p_chi2, _, _ = stats.chi2_contingency(ct)
    print(f"\n  Test χ² (paiement <-> retour) : p={p_chi2:.4f} -> "
          f"{'[OK] Lien significatif' if p_chi2 < 0.05 else 'Pas de lien'}")

    # ── 3.3 Taux de retour par tranche de prix ────────────────────
    df['tranche_prix'] = pd.qcut(
        df['price'].fillna(df['price'].median()),
        q=4,
        labels=['Entrée de gamme', 'Milieu bas', 'Milieu haut', 'Premium']
    )

    retours_prix = df.groupby('tranche_prix')['is_returned'].mean() * 100

    print("\n  TAUX DE RETOUR PAR TRANCHE DE PRIX :")
    for tranche, taux in retours_prix.items():
        print(f"    {tranche:20s} : {taux:.1f}%")

    # ── 3.4 Évolution mensuelle du taux de retour ─────────────────
    df['mois'] = pd.to_datetime(df['date']).dt.to_period('M').astype(str)
    taux_retour_mensuel = df.groupby('mois')['is_returned'].mean() * 100

    print(f"\n  TAUX RETOUR MENSUEL (min/max) :")
    print(f"    Min : {taux_retour_mensuel.min():.1f}% ({taux_retour_mensuel.idxmin()})")
    print(f"    Max : {taux_retour_mensuel.max():.1f}% ({taux_retour_mensuel.idxmax()})")

    # ── 3.5 Recommandations ───────────────────────────────────────
    print("\n  [IDEE] RECOMMANDATIONS (générées automatiquement) :")
    cat_haut_retour = retours_cat[retours_cat['taux_retour_pct'] > 12].index.tolist()
    if cat_haut_retour:
        print(f"  -> Améliorer les fiches produit pour : {', '.join(cat_haut_retour)}")

    if retours_paiement['taux_pct'].max() > retours_paiement['taux_pct'].mean() * 1.5:
        mode_risque = retours_paiement['taux_pct'].idxmax()
        print(f"  -> Examiner les commandes par {mode_risque} (taux de retour élevé)")

    resultats['recommandations_retours'] = {
        'categories_problematiques': cat_haut_retour,
        'taux_retour_mensuel': taux_retour_mensuel
    }

    return resultats


# ════════════════════════════════════════════════════════════════════
# Q4 — PRÉVISIONS FINANCIÈRES : FORECAST Q1 N+1
# ════════════════════════════════════════════════════════════════════

def prevoir_ca(df: pd.DataFrame,
               horizon_mois: int = 3) -> dict:
    """
    Prévision simple du CA pour les prochains mois.
    Utilise la régression linéaire sur la tendance temporelle.

    Paramètres :
      horizon_mois : Nombre de mois à prévoir (défaut : 3 = Q1 N+1)
    """
    print("\n" + "="*65)
    print("Q4 — PRÉVISIONS FINANCIÈRES")
    print("="*65)

    # ── CA mensuel historique ─────────────────────────────────────
    df['date'] = pd.to_datetime(df['date'])
    df['mois'] = df['date'].dt.to_period('M')
    ca_mensuel = df.groupby('mois')['total_amount'].sum()
    ca_mensuel.index = ca_mensuel.index.astype(str)

    n_mois = len(ca_mensuel)
    print(f"\n  Données historiques : {n_mois} mois")
    print(f"  CA min/max mensuel  : {ca_mensuel.min():,.0f} / {ca_mensuel.max():,.0f} €")

    # ── Régression linéaire (tendance) ───────────────────────────
    X = np.arange(n_mois).reshape(-1, 1)
    # Indice temporel : 0, 1, 2, ..., n_mois-1
    # reshape(-1, 1) : transformer en colonne (requis par sklearn)

    y = ca_mensuel.values
    # Valeurs du CA mensuel

    modele = LinearRegression()
    modele.fit(X, y)
    # fit(X, y) : entraîner le modèle de régression linéaire

    # Qualité de la tendance
    r2 = modele.score(X, y)
    # score() sur LinearRegression -> R² (coefficient de détermination)

    pente = modele.coef_[0]
    # coef_[0] : pente de la droite de régression
    # Positif -> tendance haussière, Négatif -> tendance baissière

    print(f"\n  RÉGRESSION LINÉAIRE :")
    print(f"  Pente (tendance)   : {pente:+,.0f} €/mois")
    print(f"  R²                 : {r2:.3f}")
    if r2 > 0.7:
        print("  [OK] Bonne qualité d'ajustement (tendance fiable)")
    elif r2 > 0.4:
        print("  [ATTENTION]  Qualité d'ajustement modérée (tendance approximative)")
    else:
        print("  [X] Mauvais ajustement (forte saisonnalité ou irrégularité)")

    # ── Prévision des prochains mois ─────────────────────────────
    X_futur = np.arange(n_mois, n_mois + horizon_mois).reshape(-1, 1)
    # Indices futurs : n_mois, n_mois+1, ..., n_mois+horizon-1

    ca_prevu = modele.predict(X_futur)
    # predict(X) : calculer y_prevu = pente × X + intercept

    # Intervalles de confiance (±1.96 × erreur standard)
    residus = y - modele.predict(X)
    # Résidus = CA réel - CA prédit
    sigma = np.std(residus)
    # Écart-type des résidus (mesure l'erreur du modèle)
    ic_95 = 1.96 * sigma
    # 1.96 × sigma = IC 95% (en supposant résidus normaux)

    print(f"\n  PRÉVISIONS :")
    for i, ca in enumerate(ca_prevu, 1):
        print(f"  M+{i} : {ca:>12,.0f} € (IC 95%: [{ca-ic_95:,.0f}, {ca+ic_95:,.0f}])")

    # Prévision cumulée du trimestre
    ca_trim_prevu = ca_prevu.sum()
    ca_trim_ic    = 3 * ic_95
    print(f"\n  PRÉVISION TRIMESTRIELLE (Q1 N+1) :")
    print(f"  {ca_trim_prevu:>12,.0f} € ± {ca_trim_ic:,.0f} €")

    # ── Visualisation ─────────────────────────────────────────────
    fig, ax = plt.subplots(figsize=(14, 6))

    # Données historiques
    x_hist = np.arange(n_mois)
    ax.bar(x_hist, ca_mensuel.values, color='#2196F3', alpha=0.6,
           label='CA historique', width=0.8)

    # Tendance historique
    ax.plot(x_hist, modele.predict(X),
            'b--', linewidth=2, label=f'Tendance (R²={r2:.2f})', alpha=0.8)

    # Prévisions
    x_futur = np.arange(n_mois, n_mois + horizon_mois)
    ax.bar(x_futur, ca_prevu, color='#FF5722', alpha=0.7,
           label='Prévisions', width=0.8)
    ax.errorbar(
        x_futur, ca_prevu,
        yerr=ic_95,
        fmt='none', color='black', capsize=5, linewidth=2,
        label='IC 95%'
    )
    # errorbar : afficher des barres d'erreur
    # fmt='none' : pas de ligne entre les points
    # yerr : valeur de l'erreur (±ic_95)

    # Ligne de séparation historique/futur
    ax.axvline(
        n_mois - 0.5,
        color='gray', linestyle=':', linewidth=2, alpha=0.7
    )
    ax.text(
        n_mois - 0.5, ax.get_ylim()[1] * 0.95,
        '<- Historique | Prévision ->',
        ha='center', fontsize=9, color='gray'
    )

    # Ticks
    tous_labels = list(ca_mensuel.index) + [f'M+{i}' for i in range(1, horizon_mois+1)]
    ax.set_xticks(range(len(tous_labels)))
    ax.set_xticklabels(tous_labels, rotation=45, ha='right', fontsize=8)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, p: f'{y/1000:.0f}k'))
    ax.set_title("Prévision du CA Mensuel — Régression Linéaire", fontweight='bold')
    ax.set_xlabel("Mois")
    ax.set_ylabel("CA (k€)")
    ax.legend(loc='upper left', fontsize=9)
    ax.grid(True, alpha=0.3, axis='y')

    plt.tight_layout()
    fig.savefig("reports/figures/previsions_ca.png", dpi=150, bbox_inches='tight')
    plt.close(fig)
    print("\n  [VIZ] Graphique prévisions sauvegardé")

    return {
        'ca_mensuel': ca_mensuel,
        'ca_prevu': ca_prevu,
        'r2': r2,
        'pente': pente,
        'ic_95': ic_95,
        'ca_trim_prevu': ca_trim_prevu
    }


# ════════════════════════════════════════════════════════════════════
# SYNTHÈSE : RAPPORT EXÉCUTIF COMPLET
# ════════════════════════════════════════════════════════════════════

def generer_rapport_executif(resultats_clients: dict,
                              resultats_produits: dict,
                              resultats_retours: dict,
                              resultats_previsions: dict) -> str:
    """
    Génère le rapport textuel formaté pour le comité de direction.
    """

    rapport = f"""
╔═══════════════════════════════════════════════════════════════════════╗
║                RAPPORT EXÉCUTIF — ShopSmart SARL                     ║
║                Analyse Annuelle — Comité de Direction                 ║
╠═══════════════════════════════════════════════════════════════════════╣

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Q1. ANALYSE CLIENTS (Fatou Diallo, Marketing)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  Base clients :
  -> {resultats_clients['kpis']['nb_clients']:,} clients au total
  -> {resultats_clients['kpis']['clients_actifs']:,} clients actifs (90 derniers jours)
  -> ARPU : {resultats_clients['kpis']['arpu']:.0f} €/client
  -> Panier moyen : {resultats_clients['kpis']['panier_moyen']:.0f} €
  -> LTV estimée sur 2 ans : {resultats_clients['ltv_simple']:.0f} €
  -> Taux de churn : {resultats_clients['taux_churn']:.1f}%
  -> Coefficient de Gini : {resultats_clients['gini']:.3f}

  RECOMMANDATION MARKETING :
  {'-> Concentration élevée (Gini > 0.5) : cibler les top 20% clients' if resultats_clients['gini'] > 0.5 else '-> Distribution équilibrée : stratégie de masse appropriée'}
  -> Lancer un programme de fidélité pour les "Clients À Risque" (RFM)
  -> Email de réactivation pour les clients inactifs > 90 jours

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Q2. ANALYSE PRODUITS (Ousmane Sow, Commercial)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  Analyse ABC du catalogue :
  -> Classe A : {resultats_produits['resume_abc'].loc['A', 'nb_produits'] if 'A' in resultats_produits['resume_abc'].index else 'N/A'} produits
    génèrent {resultats_produits['resume_abc'].loc['A', 'pct_ca']:.1f}% du CA (vache à lait)
  -> Classe C : produits à faible contribution — envisager retrait

  RECOMMANDATIONS COMMERCIALES :
  -> Consolider le stock sur les produits Classe A
  -> Analyser les produits à fort taux de retour avec les fournisseurs
  -> Campagne promotionnelle sur les produits Classe B pour les upgrader en A

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Q3. ANALYSE RETOURS (Aminata Ndiaye, Opérations)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  Impact financier :
  -> {resultats_retours['kpis_retours']['nb_retours']:,} retours traités
  -> Taux de retour global : {resultats_retours['kpis_retours']['taux_retour']:.1f}%
  -> Coût total estimé : {resultats_retours['kpis_retours']['cout_total']:,.0f} €

  RECOMMANDATIONS OPÉRATIONNELLES :
  -> Améliorer les fiches produits (photos 360°, guide des tailles)
  -> Délai de retour actuel : 30 jours — Réduire à 14 jours ?
  -> Mettre en place un programme de "keep it" (rabais pour garder l'article)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Q4. PRÉVISIONS FINANCIÈRES (Ibrahima Ba, Finance)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  Tendance : {'+' if resultats_previsions['pente'] > 0 else ''}{resultats_previsions['pente']:,.0f} €/mois (R²={resultats_previsions['r2']:.2f})
  
  Prévision Q1 N+1 :
  -> CA estimé : {resultats_previsions['ca_trim_prevu']:>12,.0f} €
  -> Intervalle de confiance 95% : ±{resultats_previsions['ic_95']*3:,.0f} €

  RECOMMANDATIONS FINANCIÈRES :
  -> Budgéter les coûts de retours : {resultats_retours['kpis_retours']['cout_total']/12:,.0f} €/mois
  -> Objectif : réduire le taux de retour de 2 points -> économie estimée
  -> Investir dans la fidélisation (LTV augmentation > CAC)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ACTIONS PRIORITAIRES (Quick Wins)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  SEMAINE 1-2 :
  -> Email de réactivation aux clients inactifs > 90 jours
  -> Retirer les produits Classe C avec taux retour > 20%

  MOIS 1-3 :
  -> Lancer programme fidélité pour les Champions RFM
  -> Améliorer fiches produits des catégories à fort retour
  -> Mettre en place le monitoring mensuel des KPIs

  TRIMESTRE :
  -> Analyse tarifaire (élasticité-prix par catégorie)
  -> Test A/B sur les pages produits à fort taux de retour

╚═══════════════════════════════════════════════════════════════════════╝
"""

    return rapport


# ════════════════════════════════════════════════════════════════════
# POINT D'ENTRÉE PRINCIPAL
# ════════════════════════════════════════════════════════════════════

def analyser_cas_business_complet(df: pd.DataFrame) -> None:
    """
    Lance l'analyse business complète pour le comité de direction.
    À appeler depuis main.py.
    """
    Path("reports/figures").mkdir(parents=True, exist_ok=True)

    # Lancer les 4 analyses
    resultats_clients    = analyser_clients(df)
    resultats_produits   = analyser_produits(df)
    resultats_retours    = analyser_retours(df, cout_retour_fixe=25.0)
    resultats_previsions = prevoir_ca(df, horizon_mois=3)

    # Générer le rapport exécutif
    rapport = generer_rapport_executif(
        resultats_clients,
        resultats_produits,
        resultats_retours,
        resultats_previsions
    )

    # Afficher le rapport
    print(rapport)

    # Sauvegarder le rapport texte
    rapport_path = Path("reports/rapport_executif_shopsmart.txt")
    rapport_path.write_text(rapport, encoding='utf-8')
    print(f"\n[RAPPORT] Sauvegardé : {rapport_path}")

    print("\n[PARTIE 7] Analyse business terminée [OK]")
    print("  Fichiers créés :")
    print("  -> reports/figures/analyse_abc.png")
    print("  -> reports/figures/previsions_ca.png")
    print("  -> reports/rapport_executif_shopsmart.txt")
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  EXPLICATION LIGNE PAR LIGNE (POINTS CLÉS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

COMPRENDRE PD.CUT VS PD.QCUT (rappel) :
  pd.cut(age, bins=[18,30,45,65,100], labels=['18-30','31-45','46-65','65+'])
  -> Intervalles de LARGEUR FIXE définis manuellement
  -> Ici : [18,30], [30,45], [45,65], [65,100]
  -> Utile quand les bornes ont un sens métier (tranches d'âge)

  pd.qcut(valeur, q=4, labels=[...])
  -> Intervalles de TAILLE ÉGALE (même nb de valeurs dans chaque)
  -> Utile quand on veut des quartiles, quintiles...

COMPRENDRE MODELE.COEF_[0] :
  modele = LinearRegression()
  modele.fit(X, y)
  modele.coef_[0]   -> pente de la droite
  modele.intercept_ -> ordonnée à l'origine

  La droite est : y = coef_[0] × X + intercept_
  Si coef_[0] = 5000 -> CA augmente de 5000 €/mois

COMPRENDRE ERRORBAR :
  ax.errorbar(x, y, yerr=erreur, fmt='none', capsize=5)
  -> Affiche des barres d'erreur verticales de ±erreur autour de chaque (x, y)
  -> fmt='none' : pas de symbole ni de ligne entre les points
  -> capsize : taille des "bouchons" aux extrémités des barres

COMPRENDRE GINI = 1 - 2×TRAPZ :
  np.trapz(y, x) calcule l'intégrale numérique par la règle des trapèzes.
  L'aire sous la courbe de Lorenz × 2 = 1 - Gini
  Donc Gini = 1 - 2 × (aire sous la courbe de Lorenz)
  
  Gini = 0 : tous les clients génèrent exactement le même CA
  Gini = 1 : un seul client génère tout le CA

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────

EX 7.1 — KPIs manuels
  Calculez manuellement (sans utiliser les fonctions du module) :
  a) L'ARPU mensuel pour chaque mois de 2023
  b) Le panier moyen par catégorie
  c) Le taux de churn avec un seuil de 60 jours d'inactivité

EX 7.2 — ABC simplifié
  Classez les 20 produits du catalogue en A, B, C.
  Tracez un simple barplot horizontal coloré par classe (A=vert, B=orange, C=rouge).

── NIVEAU INTERMÉDIAIRE ───────────────────────────────────────────

EX 7.3 — Prévision avec saisonnalité
  La régression linéaire ne capte pas la saisonnalité.
  Améliorez la prévision en :
  a) Calculant le facteur saisonnier mensuel moyen
     (= CA_mois / CA_tendance pour chaque mois historique)
  b) Multipliant les prévisions linéaires par le facteur saisonnier correspondant

EX 7.4 — Analyse de la rentabilité
  Ajoutez une colonne 'marge_brute' au dataset :
    marge_brute = total_amount - prix_achat_estime
    prix_achat_estime = price * 0.6  (marge de 40% supposée)
  Calculez la marge brute par catégorie et par région.
  Quel est le segment le plus rentable ?

EX 7.5 — Dashboard exécutif
  Créez un dashboard de 6 graphiques pour le comité de direction :
  1. KPI scorecard textuel
  2. Évolution CA mensuel + prévisions
  3. ABC : parts du CA
  4. Top 5 produits vs Flop 5
  5. Taux de retour par catégorie
  6. Cohortes (top 6 mois)

── NIVEAU AVANCÉ ──────────────────────────────────────────────────

EX 7.6 — LTV avancée avec taux de rétention
  Calculez la LTV avec la formule complète :
    LTV = (Panier moyen × Marge %) / (1 + Taux actualisation - Taux rétention)
  
  Paramètres :
  - Taux actualisation = 10% annuel (coût du capital)
  - Taux de rétention = 1 - taux_churn mesuré
  - Marge = 40% (supposée)

  Segmentez les clients par LTV et visualisez la distribution.

EX 7.7 — Rapport automatique en PDF
  Combinez les analyses des parties 5, 6 et 7 pour générer un PDF
  de 5 pages entièrement automatisé avec PdfPages.
  Le PDF doit inclure :
  - Page 1 : KPIs + scorecard
  - Page 2 : Dashboard visualisation (12 graphiques)
  - Page 3 : RFM dashboard
  - Page 4 : Cohortes + ABC
  - Page 5 : Prévisions + rapport texte

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉ EX 7.3 (Prévision avec saisonnalité)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression

def prevoir_avec_saisonnalite(df: pd.DataFrame,
                               horizon_mois: int = 3) -> pd.DataFrame:
    """
    Prévision avec décomposition tendance × saisonnalité.
    Méthode classique de séries temporelles.
    """
    df_temp = df.copy()
    df_temp['date'] = pd.to_datetime(df_temp['date'])
    df_temp['mois_periode'] = df_temp['date'].dt.to_period('M')
    df_temp['mois_num'] = df_temp['date'].dt.month
    # mois_num : numéro du mois (1-12) pour le facteur saisonnier

    ca_mensuel = df_temp.groupby('mois_periode')['total_amount'].sum()
    n = len(ca_mensuel)

    # ── Étape 1 : Calculer la tendance par régression ─────────────
    X = np.arange(n).reshape(-1, 1)
    y = ca_mensuel.values

    modele = LinearRegression()
    modele.fit(X, y)
    tendance = modele.predict(X)
    # tendance : valeurs prédites par la droite de régression

    # ── Étape 2 : Calculer les résidus par rapport à la tendance ──
    ratio = y / tendance
    # ratio = CA_réel / CA_tendance = composante saisonnière + bruit

    # Associer chaque mois de l'historique à son numéro de mois
    mois_num_hist = df_temp.groupby('mois_periode')['mois_num'].first()

    # Facteur saisonnier mensuel moyen
    df_ratio = pd.DataFrame({'mois_num': mois_num_hist.values, 'ratio': ratio})
    facteurs_saison = df_ratio.groupby('mois_num')['ratio'].mean()
    # Pour chaque mois (1-12), moyenne du ratio sur toutes les années

    # Normaliser : la somme des 12 facteurs doit être ≈ 12
    facteurs_saison = facteurs_saison * 12 / facteurs_saison.sum()
    # Normalisation : s'assurer que la saisonnalité ne biaise pas la tendance

    print("Facteurs saisonniers (1 = pas de saisonnalité) :")
    for m, f in facteurs_saison.items():
        mois_nom = ['Jan','Fev','Mar','Avr','Mai','Jun',
                    'Jul','Aou','Sep','Oct','Nov','Dec'][m-1]
        print(f"  {mois_nom} : {f:.3f} {'^' if f > 1 else 'v' if f < 0.95 else '->'}")

    # ── Étape 3 : Prévision = Tendance × Saisonnalité ─────────────
    X_futur = np.arange(n, n + horizon_mois).reshape(-1, 1)
    tendance_future = modele.predict(X_futur)
    # Tendance extrapolée (droite continue)

    # Numéro de mois futurs
    dernier_mois = ca_mensuel.index[-1].month
    # Dernier mois observé
    mois_futurs = [(dernier_mois + i - 1) % 12 + 1
                   for i in range(1, horizon_mois + 1)]
    # Calcul du numéro de mois cyclique (après décembre -> janvier)

    # Facteur saisonnier pour chaque mois futur
    facteurs_futurs = np.array([
        facteurs_saison.get(m, 1.0) for m in mois_futurs
    ])

    # Prévision finale = tendance × saisonnalité
    previsions = tendance_future * facteurs_futurs

    print(f"\nPrévisions avec saisonnalité :")
    for i, (prev, mois) in enumerate(zip(previsions, mois_futurs)):
        mois_nom = ['Jan','Fev','Mar','Avr','Mai','Jun',
                    'Jul','Aou','Sep','Oct','Nov','Dec'][mois-1]
        print(f"  M+{i+1} ({mois_nom}) : {prev:>12,.0f} €")

    # ── Visualisation ─────────────────────────────────────────────
    fig, ax = plt.subplots(figsize=(14, 6))
    x_hist = np.arange(n)

    # CA historique
    ax.plot(x_hist, y, 'b-o', linewidth=2, markersize=5, label='CA historique')
    # Tendance
    ax.plot(x_hist, tendance, 'b--', linewidth=1.5, alpha=0.6, label='Tendance')
    # Prévisions
    x_futur = np.arange(n, n + horizon_mois)
    ax.bar(x_futur, previsions, color='#FF5722', alpha=0.7, label='Prévisions', width=0.6)

    ax.axvline(n - 0.5, color='gray', linestyle=':', linewidth=2)
    ax.set_title("Prévision CA avec Tendance + Saisonnalité", fontweight='bold')
    ax.set_ylabel("CA (€)")
    ax.legend()
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, p: f'{v/1000:.0f}k'))

    plt.tight_layout()
    plt.savefig("reports/figures/prevision_saisonnalite.png", dpi=150, bbox_inches='tight')
    plt.close(fig)

    return previsions
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[NOTE] RÉCAPITULATIF PARTIE 7
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Ce que vous avez appris :

[OK] Calculer des KPIs clients avancés (LTV, ARPU, Gini, Churn)
[OK] Analyse ABC (Pareto) du catalogue produits
[OK] Quantifier l'impact financier des retours
[OK] Forecast par régression linéaire + IC 95%
[OK] Décomposition saisonnalité + tendance
[OK] Générer un rapport exécutif automatisé
[OK] Structurer une analyse autour de questions business

Fichiers créés :
  reports/figures/analyse_abc.png               <- Pareto produits
  reports/figures/previsions_ca.png             <- Prévisions Q1 N+1
  reports/figures/prevision_saisonnalite.png    <- Prévision améliorée
  reports/rapport_executif_shopsmart.txt         <- Rapport texte

Prochaine étape : PARTIE 8 — Projet Final Complet
  (Pipeline intégré, rapport final Markdown, insights,
   recommandations et livrable professionnel)

================================================================================
FIN PARTIE 7
================================================================================

================================================================================
DATAINSIGHT PRO — PARTIE 8 : PROJET FINAL COMPLET
ShopSmart SARL — Pipeline Intégral & Livrable Professionnel
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
INTRODUCTION : LE GRAND FINAL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Vous avez parcouru un long chemin depuis la Partie 1. Vous savez maintenant :
  [OK] Charger et explorer un dataset (Partie 1 & 2)
  [OK] Nettoyer les données proprement (Partie 3)
  [OK] Réaliser une EDA complète (Partie 4)
  [OK] Créer des visualisations professionnelles (Partie 5)
  [OK] Maîtriser les analyses avancées : RFM, cohortes, panier (Partie 6)
  [OK] Répondre à de vraies questions business (Partie 7)

Dans cette Partie 8 finale, vous allez assembler TOUT cela en un projet
professionnel complet, tel qu'on le livre dans le monde réel :

  1. Un pipeline de données intégral (main.py finalisé)
  2. Un rapport Markdown automatisé (reports/final_report.md)
  3. Un fichier de configuration (config.py)
  4. Des tests unitaires basiques (tests/test_pipeline.py)
  5. Un README professionnel
  6. Les insights business finals et recommandations stratégiques

À la fin de cette partie, vous aurez un projet Python complet que vous pourrez
montrer fièrement dans votre portfolio ou présenter à une direction générale.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 42 : ARCHITECTURE FINALE DU PROJET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Voici la structure complète du projet tel qu'il sera livré :

python_data_project/
│
├── [DOSSIER] data/
│   ├── sales_data.csv              <- Dataset brut (généré en Partie 1)
│   └── sales_data_clean.csv        <- Dataset nettoyé (généré automatiquement)
│
├── [DOSSIER] src/                         <- Modules Python réutilisables
│   ├── __init__.py
│   ├── data_loader.py              <- Chargement (Partie 1)
│   ├── data_cleaning.py            <- Nettoyage (Partie 3)
│   ├── analysis.py                 <- Analyses EDA (Partie 4)
│   ├── visualization.py            <- Graphiques (Partie 5)
│   └── utils.py                    <- Utilitaires (Partie 1)
│
├── [DOSSIER] reports/
│   ├── final_report.md             <- Rapport Markdown automatisé <- NOUVEAU
│   ├── rapport_executif.txt        <- Rapport texte (Partie 7)
│   └── [DOSSIER] figures/                 <- Tous les graphiques PNG/PDF
│       ├── dashboard_complet.png
│       ├── rfm_dashboard.png
│       ├── cohortes_retention.png
│       ├── analyse_abc.png
│       └── previsions_ca.png
│
├── [DOSSIER] tests/                       <- Tests unitaires <- NOUVEAU
│   ├── __init__.py
│   └── test_pipeline.py
│
├── config.py                       <- Configuration centralisée <- NOUVEAU
├── main.py                         <- Pipeline principal (version finale)
├── generate_dataset.py             <- Génération du dataset (Partie 1)
├── requirements.txt                <- Dépendances
└── README.md                       <- Documentation du projet <- NOUVEAU

Pourquoi cette structure ?
  - config.py : tous les paramètres en un seul endroit (chemins, seuils, etc.)
  - tests/ : vérifier que le code fonctionne avant de livrer
  - README.md : indispensable pour tout projet partagé ou sur GitHub
  - Séparation data brute / data nettoyée : traçabilité et reproductibilité

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 43 : config.py — CENTRALISER LA CONFIGURATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Problème courant des débutants : les chemins de fichiers et paramètres sont
éparpillés dans tout le code. Si un chemin change, vous devez modifier 10 fichiers.

La solution : un fichier config.py central. C'est la première bonne pratique
de tout projet professionnel.

────────────────────────────────────────────────────────────────────────────────
FICHIER : config.py
────────────────────────────────────────────────────────────────────────────────

# =============================================================================
# config.py — Configuration Centrale du Projet DataInsight Pro
# ShopSmart SARL — Tous les paramètres en un seul endroit
# =============================================================================

import os
from pathlib import Path

# =============================================================================
# 1. CHEMINS DU PROJET
# =============================================================================

# Répertoire racine du projet (là où se trouve config.py)
# Path(__file__) donne le chemin de config.py lui-même
# .parent donne le dossier parent = la racine du projet
RACINE_PROJET = Path(__file__).parent

# Dossiers principaux
# On utilise / pour joindre les chemins (Path supporte cet opérateur)
DOSSIER_DATA         = RACINE_PROJET / "data"
DOSSIER_RAPPORTS     = RACINE_PROJET / "reports"
DOSSIER_FIGURES      = RACINE_PROJET / "reports" / "figures"
DOSSIER_SRC          = RACINE_PROJET / "src"
DOSSIER_TESTS        = RACINE_PROJET / "tests"

# Fichiers de données
FICHIER_DONNEES_BRUTES   = DOSSIER_DATA / "sales_data.csv"
FICHIER_DONNEES_NETTOYEES = DOSSIER_DATA / "sales_data_clean.csv"

# Fichiers de rapports
FICHIER_RAPPORT_MD       = DOSSIER_RAPPORTS / "final_report.md"
FICHIER_RAPPORT_EXECUTIF = DOSSIER_RAPPORTS / "rapport_executif.txt"

# Fichiers de figures
FICHIER_DASHBOARD        = DOSSIER_FIGURES / "dashboard_complet.png"
FICHIER_DASHBOARD_PDF    = DOSSIER_FIGURES / "dashboard_complet.pdf"
FICHIER_RFM              = DOSSIER_FIGURES / "rfm_dashboard.png"
FICHIER_COHORTES         = DOSSIER_FIGURES / "cohortes_retention.png"
FICHIER_ABC              = DOSSIER_FIGURES / "analyse_abc.png"
FICHIER_PREVISIONS       = DOSSIER_FIGURES / "previsions_ca.png"

# =============================================================================
# 2. PARAMÈTRES DU DATASET
# =============================================================================

# Reproductibilité : toujours la même graine aléatoire
GRAINE_ALEATOIRE = 42

# Colonnes attendues dans le dataset
COLONNES_ATTENDUES = [
    "order_id", "date", "customer_id", "customer_age",
    "region", "category", "product", "quantity",
    "unit_price", "payment_method", "returned", "revenue"
]

# Types de colonnes après nettoyage
TYPES_COLONNES = {
    "order_id"       : "string",
    "customer_id"    : "string",
    "customer_age"   : "float",
    "quantity"       : "int",
    "unit_price"     : "float",
    "revenue"        : "float",
    "returned"       : "bool",
}

# =============================================================================
# 3. PARAMÈTRES DE NETTOYAGE
# =============================================================================

# Valeur maximale acceptable pour la quantité (au-delà = suspect)
QUANTITE_MAX_B2C  = 50    # Un particulier achète rarement plus de 50 unités

# Seuils pour la détection des outliers (Winsorisation)
PERCENTILE_BAS    = 0.01  # 1er percentile
PERCENTILE_HAUT   = 0.99  # 99e percentile

# Colonnes à winsoriser
COLONNES_WINSORISATION = ["revenue", "unit_price", "quantity"]

# =============================================================================
# 4. PARAMÈTRES D'ANALYSE RFM
# =============================================================================

# Date de référence pour calculer la récence
# En production, on utiliserait : date.today()
# En analyse rétrospective, on utilise une date fixe
DATE_REFERENCE_RFM = "2024-01-01"

# Nombre de quartiles pour le scoring RFM
QUARTILES_RFM = 5

# Labels des segments RFM
SEGMENTS_RFM = {
    "Champions"         : "R≥4, F≥4, M≥4",
    "Loyaux"            : "F≥4",
    "Potentiels"        : "R≥3, F≤3",
    "Nouveaux"          : "R=5, F=1",
    "En risque"         : "R≤2, F≥3",
    "Perdus"            : "R=1, F=1",
    "Autres"            : "Tout le reste",
}

# =============================================================================
# 5. PARAMÈTRES DE VISUALISATION
# =============================================================================

# Palette de couleurs ShopSmart (identité visuelle de l'entreprise)
COULEURS_SHOPSMART = {
    "primaire"   : "#2C3E50",   # Bleu marine foncé
    "secondaire" : "#E74C3C",   # Rouge ShopSmart
    "accent"     : "#3498DB",   # Bleu ciel
    "succes"     : "#2ECC71",   # Vert
    "attention"  : "#F39C12",   # Orange
    "neutre"     : "#95A5A6",   # Gris
}

# Palette pour les catégories de produits
COULEURS_CATEGORIES = {
    "Électronique"    : "#3498DB",
    "Vêtements"       : "#E74C3C",
    "Maison & Jardin" : "#2ECC71",
    "Beauté & Santé"  : "#F39C12",
}

# Résolution des figures exportées (DPI = Dots Per Inch)
DPI_FIGURE = 300

# Taille par défaut des figures (largeur, hauteur en pouces)
TAILLE_FIGURE_STANDARD = (12, 7)
TAILLE_FIGURE_LARGE    = (16, 10)
TAILLE_FIGURE_CARREE   = (10, 10)

# Style Matplotlib à utiliser
STYLE_MATPLOTLIB = "seaborn-v0_8-whitegrid"

# =============================================================================
# 6. PARAMÈTRES DU RAPPORT
# =============================================================================

# Informations de l'entreprise pour le rapport
ENTREPRISE = {
    "nom"        : "ShopSmart SARL",
    "secteur"    : "E-commerce",
    "pays"       : "France",
    "analyste"   : "Équipe Data Analytics",
    "periode"    : "Année 2023",
}

# Seuils pour les recommandations automatiques
SEUIL_TAUX_RETOUR_ALERTE   = 0.15   # Alerte si > 15% de retours
SEUIL_CROISSANCE_POSITIVE  = 0.05   # Croissance positive si > 5%
SEUIL_CLIENT_VIP_PERCENTILE = 0.10  # Top 10% = clients VIP

# =============================================================================
# 7. FONCTION UTILITAIRE : CRÉER LES DOSSIERS MANQUANTS
# =============================================================================

def creer_dossiers():
    """
    Crée tous les dossiers nécessaires s'ils n'existent pas.

    Cette fonction est appelée au début du pipeline principal.
    Ainsi, même si quelqu'un clone le projet depuis GitHub (sans les dossiers
    vides), tout fonctionne immédiatement.

    exist_ok=True signifie : ne pas planter si le dossier existe déjà.
    """
    dossiers = [
        DOSSIER_DATA,
        DOSSIER_RAPPORTS,
        DOSSIER_FIGURES,
        DOSSIER_TESTS,
    ]
    for dossier in dossiers:
        dossier.mkdir(parents=True, exist_ok=True)
        # parents=True crée les dossiers intermédiaires si nécessaire
        # Ex: si "reports/" n'existe pas, il est créé avant "reports/figures/"


# =============================================================================
# 8. AFFICHAGE DE LA CONFIGURATION (utile pour déboguer)
# =============================================================================

def afficher_config():
    """
    Affiche un résumé de la configuration actuelle.
    Utile pour vérifier que tous les chemins sont corrects.
    """
    print("=" * 60)
    print("CONFIGURATION DATAINSIGHT PRO")
    print("=" * 60)
    print(f"Racine du projet  : {RACINE_PROJET}")
    print(f"Données brutes    : {FICHIER_DONNEES_BRUTES}")
    print(f"Données nettoyées : {FICHIER_DONNEES_NETTOYEES}")
    print(f"Rapport final     : {FICHIER_RAPPORT_MD}")
    print(f"Dossier figures   : {DOSSIER_FIGURES}")
    print(f"Graine aléatoire  : {GRAINE_ALEATOIRE}")
    print(f"Entreprise        : {ENTREPRISE['nom']}")
    print("=" * 60)


# Ce bloc s'exécute uniquement si on lance : python config.py
# (pas si on fait : import config)
if __name__ == "__main__":
    creer_dossiers()
    afficher_config()
    print("\n[OK] Configuration vérifiée. Tous les dossiers sont prêts.")

────────────────────────────────────────────────────────────────────────────────
EXPLICATION DÉTAILLÉE : Path vs os.path
────────────────────────────────────────────────────────────────────────────────

Python offre deux façons de gérer les chemins :

ANCIENNE MÉTHODE (os.path) :
  import os
  chemin = os.path.join("reports", "figures", "dashboard.png")
  # Résultat : "reports/figures/dashboard.png"

NOUVELLE MÉTHODE (pathlib.Path) — recommandée depuis Python 3.6 :
  from pathlib import Path
  chemin = Path("reports") / "figures" / "dashboard.png"
  # Résultat : PosixPath('reports/figures/dashboard.png')

Avantages de Path :
  [OK] Syntaxe plus lisible (l'opérateur / pour joindre)
  [OK] Compatible Windows (\ ) et Linux/Mac (/) automatiquement
  [OK] Méthodes pratiques : .exists(), .stem, .suffix, .parent, .name
  [OK] Conversion en str simple : str(chemin)

Exemples pratiques :
  p = Path("reports/figures/dashboard_complet.png")
  p.name    -> "dashboard_complet.png"    (nom du fichier)
  p.stem    -> "dashboard_complet"        (sans extension)
  p.suffix  -> ".png"                     (extension)
  p.parent  -> PosixPath("reports/figures") (dossier parent)
  p.exists() -> True ou False             (le fichier existe ?)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 44 : main.py — LE PIPELINE INTÉGRAL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

C'est le cœur du projet : le fichier qu'on lance pour tout exécuter.
Il orchestre tous les modules dans le bon ordre, comme un chef d'orchestre.

────────────────────────────────────────────────────────────────────────────────
FICHIER : main.py (VERSION FINALE COMPLÈTE)
────────────────────────────────────────────────────────────────────────────────

# =============================================================================
# main.py — Pipeline Principal DataInsight Pro
# ShopSmart SARL — Analyse Complète des Ventes 2023
#
# Usage : python main.py
#         python main.py --etape nettoyage
#         python main.py --rapport-seulement
# =============================================================================

import argparse          # Pour les arguments en ligne de commande
import sys               # Pour sys.exit() en cas d'erreur critique
import time              # Pour mesurer la durée totale
import traceback         # Pour afficher les erreurs détaillées
import warnings          # Pour contrôler les avertissements
from datetime import datetime
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")    # Mode sans interface graphique (serveur/batch)
import matplotlib.pyplot as plt

# Import de la configuration centrale
import config

# Import de tous nos modules
from src.utils import (
    afficher_separateur,
    formater_nombre,
    mesurer_temps,
)
from src.data_loader import (
    charger_donnees,
    afficher_resume_dataset,
    verifier_colonnes,
)
from src.data_cleaning import (
    nettoyer_donnees,
    generer_rapport_qualite,
)
from src.analysis import (
    calculer_statistiques_descriptives,
    analyser_correlations,
    calculer_kpis_business,
    analyser_saisonnalite,
)
from src.visualization import (
    generer_dashboard_complet,
    sauvegarder_graphique,
)

# Ignorer les avertissements non critiques
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)


# =============================================================================
# CLASSE PRINCIPALE : PIPELINE
# =============================================================================

class PipelineAnalyse:
    """
    Orchestre l'ensemble du pipeline d'analyse de données.

    Pourquoi une classe plutôt que des fonctions ?
    -> Un pipeline a un ÉTAT : données chargées, nettoyées, analysées...
    -> La classe stocke cet état dans ses attributs (self.df_brut, etc.)
    -> Les méthodes peuvent s'enchaîner naturellement

    Pattern utilisé : "Pipeline Pattern" (commun en data engineering)
    """

    def __init__(self):
        """
        Initialisation : prépare l'environnement, rien d'autre.
        """
        # Données à différentes étapes du pipeline
        self.df_brut     = None   # Dataset tel que chargé
        self.df_propre   = None   # Dataset après nettoyage
        self.kpis        = {}     # Indicateurs clés calculés
        self.insights    = []     # Liste des insights générés

        # Suivi du pipeline
        self.etapes_realisees = []
        self.heure_debut      = datetime.now()
        self.erreurs          = []

        # Initialiser la configuration
        config.creer_dossiers()
        config.afficher_config()

    # -------------------------------------------------------------------------
    # ÉTAPE 0 : VALIDATION DE L'ENVIRONNEMENT
    # -------------------------------------------------------------------------

    def valider_environnement(self):
        """
        Vérifie que tout est en place avant de commencer.
        Mieux vaut détecter les problèmes tôt !
        """
        afficher_separateur("ÉTAPE 0 : VALIDATION DE L'ENVIRONNEMENT")

        problemes = []

        # Vérifier que le fichier de données existe
        if not config.FICHIER_DONNEES_BRUTES.exists():
            problemes.append(
                f"[X] Fichier introuvable : {config.FICHIER_DONNEES_BRUTES}\n"
                f"   -> Lancez d'abord : python generate_dataset.py"
            )

        # Vérifier les imports des bibliothèques essentielles
        bibliotheques = {
            "pandas"     : "pd",
            "numpy"      : "np",
            "matplotlib" : "matplotlib",
            "seaborn"    : "sns",
            "scipy"      : "scipy",
        }
        for lib, alias in bibliotheques.items():
            try:
                __import__(lib)
                print(f"  [OK] {lib}")
            except ImportError:
                problemes.append(f"[X] Bibliothèque manquante : {lib}")
                print(f"  [X] {lib} — MANQUANT")

        if problemes:
            print("\n[ATTENTION]  PROBLÈMES DÉTECTÉS :")
            for p in problemes:
                print(f"  {p}")
            print("\n-> Corrigez ces problèmes avant de continuer.")
            sys.exit(1)   # Code de sortie 1 = erreur

        print("\n[OK] Environnement validé. Démarrage du pipeline...")
        self.etapes_realisees.append("validation")
        return self   # Retourner self permet le chaînage : pipe.etape1().etape2()

    # -------------------------------------------------------------------------
    # ÉTAPE 1 : CHARGEMENT DES DONNÉES
    # -------------------------------------------------------------------------

    @mesurer_temps
    def charger(self):
        """
        Charge le dataset brut et affiche un résumé.
        """
        afficher_separateur("ÉTAPE 1 : CHARGEMENT DES DONNÉES")

        # Charger le dataset (module data_loader)
        self.df_brut = charger_donnees(str(config.FICHIER_DONNEES_BRUTES))

        # Vérifier les colonnes attendues
        colonnes_manquantes = verifier_colonnes(
            self.df_brut, config.COLONNES_ATTENDUES
        )
        if colonnes_manquantes:
            raise ValueError(
                f"Colonnes manquantes dans le dataset : {colonnes_manquantes}"
            )

        # Afficher le résumé
        afficher_resume_dataset(self.df_brut)

        # Stocker les métriques de chargement dans les KPIs
        self.kpis["nb_lignes_brutes"]   = len(self.df_brut)
        self.kpis["nb_colonnes"]        = len(self.df_brut.columns)
        self.kpis["periode_debut"]      = str(self.df_brut["date"].min())
        self.kpis["periode_fin"]        = str(self.df_brut["date"].max())

        self.etapes_realisees.append("chargement")
        return self

    # -------------------------------------------------------------------------
    # ÉTAPE 2 : NETTOYAGE DES DONNÉES
    # -------------------------------------------------------------------------

    @mesurer_temps
    def nettoyer(self):
        """
        Nettoie le dataset et génère un rapport de qualité.
        """
        afficher_separateur("ÉTAPE 2 : NETTOYAGE DES DONNÉES")

        if self.df_brut is None:
            raise RuntimeError("Appelez .charger() avant .nettoyer()")

        # Générer le rapport de qualité AVANT nettoyage
        rapport_avant = generer_rapport_qualite(self.df_brut)
        print("[GRAPHIQUE] Qualité avant nettoyage :")
        print(rapport_avant)

        # Nettoyer les données (module data_cleaning)
        self.df_propre = nettoyer_donnees(self.df_brut.copy())

        # Générer le rapport de qualité APRÈS nettoyage
        rapport_apres = generer_rapport_qualite(self.df_propre)
        print("\n[GRAPHIQUE] Qualité après nettoyage :")
        print(rapport_apres)

        # Calculer les métriques de nettoyage
        n_avant = len(self.df_brut)
        n_apres = len(self.df_propre)
        n_supprimes = n_avant - n_apres

        self.kpis["nb_lignes_nettoyees"]   = n_apres
        self.kpis["nb_lignes_supprimees"]  = n_supprimes
        self.kpis["taux_retention_lignes"] = n_apres / n_avant

        print(f"\n  Lignes avant  : {formater_nombre(n_avant)}")
        print(f"  Lignes après  : {formater_nombre(n_apres)}")
        print(f"  Supprimées    : {formater_nombre(n_supprimes)} "
              f"({n_supprimes/n_avant:.1%})")

        # Sauvegarder le dataset nettoyé
        self.df_propre.to_csv(
            config.FICHIER_DONNEES_NETTOYEES,
            index=False,
            encoding="utf-8-sig"   # utf-8-sig = UTF-8 avec BOM (pour Excel)
        )
        print(f"\n  [SAUVEGARDE] Dataset nettoyé sauvegardé : "
              f"{config.FICHIER_DONNEES_NETTOYEES}")

        self.etapes_realisees.append("nettoyage")
        return self

    # -------------------------------------------------------------------------
    # ÉTAPE 3 : ANALYSE EXPLORATOIRE
    # -------------------------------------------------------------------------

    @mesurer_temps
    def analyser(self):
        """
        Réalise l'analyse exploratoire complète.
        """
        afficher_separateur("ÉTAPE 3 : ANALYSE EXPLORATOIRE (EDA)")

        if self.df_propre is None:
            raise RuntimeError("Appelez .nettoyer() avant .analyser()")

        df = self.df_propre

        # ── 3.1 Statistiques descriptives ──────────────────────────────────
        print("  [HAUSSE] Calcul des statistiques descriptives...")
        stats = calculer_statistiques_descriptives(df)

        # ── 3.2 KPIs Business ──────────────────────────────────────────────
        print("  [ARGENT] Calcul des KPIs business...")
        kpis_business = calculer_kpis_business(df)

        # Intégrer dans le dictionnaire principal
        self.kpis.update(kpis_business)

        # ── 3.3 Analyse de saisonnalité ────────────────────────────────────
        print("  [CALENDRIER] Analyse de la saisonnalité...")
        saisonnalite = analyser_saisonnalite(df)
        self.kpis["meilleur_mois"] = saisonnalite["meilleur_mois"]
        self.kpis["pire_mois"]     = saisonnalite["pire_mois"]

        # ── 3.4 Analyse par catégorie ──────────────────────────────────────
        print("  [LABEL]  Analyse par catégorie de produit...")
        ca_par_cat = (df.groupby("category")["revenue"]
                        .sum()
                        .sort_values(ascending=False))

        self.kpis["categorie_top"]      = ca_par_cat.index[0]
        self.kpis["categorie_top_ca"]   = ca_par_cat.iloc[0]
        self.kpis["categorie_bottom"]   = ca_par_cat.index[-1]

        # ── 3.5 Analyse des retours ────────────────────────────────────────
        print("  [SYNC] Analyse des retours produits...")
        taux_retour_global = df["returned"].mean()
        self.kpis["taux_retour_global"] = taux_retour_global

        taux_retour_cat = df.groupby("category")["returned"].mean()
        self.kpis["categorie_retour_max"] = taux_retour_cat.idxmax()
        self.kpis["taux_retour_max"]      = taux_retour_cat.max()

        # ── 3.6 Génération des insights automatiques ──────────────────────
        self._generer_insights()

        # Afficher les KPIs principaux
        print("\n  ━━━━ KPIs PRINCIPAUX ━━━━")
        print(f"  CA Total         : {formater_nombre(self.kpis.get('ca_total', 0))} €")
        print(f"  Nb Commandes     : {formater_nombre(self.kpis.get('nb_commandes', 0))}")
        print(f"  Nb Clients       : {formater_nombre(self.kpis.get('nb_clients', 0))}")
        print(f"  Panier Moyen     : {self.kpis.get('panier_moyen', 0):.2f} €")
        print(f"  Taux de Retour   : {self.kpis.get('taux_retour_global', 0):.1%}")
        print(f"  Meilleur Mois    : {self.kpis.get('meilleur_mois', 'N/A')}")

        self.etapes_realisees.append("analyse")
        return self

    # -------------------------------------------------------------------------
    # ÉTAPE 4 : VISUALISATIONS
    # -------------------------------------------------------------------------

    @mesurer_temps
    def visualiser(self):
        """
        Génère tous les graphiques et les sauvegarde.
        """
        afficher_separateur("ÉTAPE 4 : GÉNÉRATION DES VISUALISATIONS")

        if self.df_propre is None:
            raise RuntimeError("Appelez .nettoyer() avant .visualiser()")

        df = self.df_propre

        # ── 4.1 Dashboard principal ────────────────────────────────────────
        print("  [DESIGN] Génération du dashboard principal...")
        fig_dashboard = generer_dashboard_complet(df)
        sauvegarder_graphique(
            fig_dashboard,
            str(config.FICHIER_DASHBOARD),
            dpi=config.DPI_FIGURE
        )
        sauvegarder_graphique(
            fig_dashboard,
            str(config.FICHIER_DASHBOARD_PDF),
            format_="pdf"
        )
        plt.close(fig_dashboard)
        print(f"    [OK] {config.FICHIER_DASHBOARD}")
        print(f"    [OK] {config.FICHIER_DASHBOARD_PDF}")

        # ── 4.2 Analyse RFM ────────────────────────────────────────────────
        print("\n  [UTILISATEURS] Génération de l'analyse RFM...")
        fig_rfm = self._generer_rfm_dashboard(df)
        sauvegarder_graphique(
            fig_rfm,
            str(config.FICHIER_RFM),
            dpi=config.DPI_FIGURE
        )
        plt.close(fig_rfm)
        print(f"    [OK] {config.FICHIER_RFM}")

        # ── 4.3 Analyse ABC produits ───────────────────────────────────────
        print("\n  [PACKAGE] Génération de l'analyse ABC produits...")
        fig_abc = self._generer_analyse_abc(df)
        sauvegarder_graphique(
            fig_abc,
            str(config.FICHIER_ABC),
            dpi=config.DPI_FIGURE
        )
        plt.close(fig_abc)
        print(f"    [OK] {config.FICHIER_ABC}")

        # ── 4.4 Prévisions de CA ───────────────────────────────────────────
        print("\n  [HAUSSE] Génération des prévisions de CA...")
        fig_prev = self._generer_previsions(df)
        sauvegarder_graphique(
            fig_prev,
            str(config.FICHIER_PREVISIONS),
            dpi=config.DPI_FIGURE
        )
        plt.close(fig_prev)
        print(f"    [OK] {config.FICHIER_PREVISIONS}")

        print(f"\n  [DOSSIER] Toutes les figures sont dans : {config.DOSSIER_FIGURES}")

        self.etapes_realisees.append("visualisation")
        return self

    # -------------------------------------------------------------------------
    # ÉTAPE 5 : GÉNÉRATION DU RAPPORT MARKDOWN
    # -------------------------------------------------------------------------

    @mesurer_temps
    def generer_rapport(self):
        """
        Génère le rapport final en format Markdown.
        """
        afficher_separateur("ÉTAPE 5 : GÉNÉRATION DU RAPPORT FINAL")

        if not self.kpis:
            raise RuntimeError("Appelez .analyser() avant .generer_rapport()")

        print("  [NOTE] Rédaction du rapport Markdown...")
        contenu_rapport = self._construire_rapport_markdown()

        # Écrire le fichier
        with open(config.FICHIER_RAPPORT_MD, "w", encoding="utf-8") as f:
            f.write(contenu_rapport)

        taille = config.FICHIER_RAPPORT_MD.stat().st_size
        print(f"  [OK] Rapport généré : {config.FICHIER_RAPPORT_MD}")
        print(f"     Taille : {taille:,} octets ({taille//1024} Ko)")

        self.etapes_realisees.append("rapport")
        return self

    # -------------------------------------------------------------------------
    # ÉTAPE 6 : RÉSUMÉ FINAL DU PIPELINE
    # -------------------------------------------------------------------------

    def afficher_resume_final(self):
        """
        Affiche le bilan complet du pipeline.
        """
        afficher_separateur("RÉSUMÉ FINAL DU PIPELINE")

        duree_totale = (datetime.now() - self.heure_debut).total_seconds()

        print(f"  Entreprise analysée  : {config.ENTREPRISE['nom']}")
        print(f"  Période              : {config.ENTREPRISE['periode']}")
        print(f"  Analyste             : {config.ENTREPRISE['analyste']}")
        print(f"  Date d'analyse       : {datetime.now().strftime('%d/%m/%Y %H:%M')}")
        print(f"  Durée totale         : {duree_totale:.1f} secondes")
        print()
        print(f"  Étapes réalisées     : {' -> '.join(self.etapes_realisees)}")
        print()

        # KPIs dans le rapport
        ca = self.kpis.get("ca_total", 0)
        print(f"  ━━━━ RÉSULTATS CLÉS 2023 ━━━━")
        print(f"  [GRAPHIQUE] CA Total          : {formater_nombre(ca)} €")
        print(f"  [SHOPPING_TROLLEY] Commandes         : {formater_nombre(self.kpis.get('nb_commandes', 0))}")
        print(f"  [UTILISATEURS] Clients uniques   : {formater_nombre(self.kpis.get('nb_clients', 0))}")
        print(f"  [CARTE] Panier moyen      : {self.kpis.get('panier_moyen', 0):.2f} €")
        print(f"  [TROPHEE] Catégorie n°1     : {self.kpis.get('categorie_top', 'N/A')}")
        print(f"  [CALENDRIER] Meilleur mois     : {self.kpis.get('meilleur_mois', 'N/A')}")
        print()

        # Insights générés
        if self.insights:
            print(f"  ━━━━ INSIGHTS AUTOMATIQUES ━━━━")
            for i, insight in enumerate(self.insights, 1):
                print(f"  {i}. {insight}")
        print()

        # Fichiers générés
        print(f"  ━━━━ FICHIERS GÉNÉRÉS ━━━━")
        fichiers = [
            config.FICHIER_DONNEES_NETTOYEES,
            config.FICHIER_DASHBOARD,
            config.FICHIER_DASHBOARD_PDF,
            config.FICHIER_RFM,
            config.FICHIER_ABC,
            config.FICHIER_PREVISIONS,
            config.FICHIER_RAPPORT_MD,
        ]
        for f in fichiers:
            existe = "[OK]" if Path(f).exists() else "[X]"
            print(f"  {existe} {f}")

        if self.erreurs:
            print(f"\n  [ATTENTION]  {len(self.erreurs)} avertissement(s) non bloquant(s) :")
            for e in self.erreurs:
                print(f"     - {e}")

        print(f"\n* Pipeline terminé avec succès en {duree_totale:.1f}s !")
        print("   Tous les fichiers sont disponibles dans le dossier 'reports/'")

    # =========================================================================
    # MÉTHODES PRIVÉES (préfixe _ = usage interne à la classe)
    # =========================================================================

    def _generer_insights(self):
        """
        Génère automatiquement des insights à partir des KPIs.
        Un 'insight' est une observation actionnelle (qui mène à une décision).
        """
        kpis = self.kpis

        # Insight sur le taux de retour
        tx_retour = kpis.get("taux_retour_global", 0)
        if tx_retour > config.SEUIL_TAUX_RETOUR_ALERTE:
            self.insights.append(
                f"[ATTENTION]  Taux de retour élevé ({tx_retour:.1%}) — "
                f"Catégorie la plus touchée : {kpis.get('categorie_retour_max', '?')}"
            )
        else:
            self.insights.append(
                f"[OK] Taux de retour maîtrisé ({tx_retour:.1%})"
            )

        # Insight sur la concentration du CA
        cat_top = kpis.get("categorie_top", "?")
        ca_total = kpis.get("ca_total", 1)
        ca_top = kpis.get("categorie_top_ca", 0)
        if ca_total > 0:
            pct_top = ca_top / ca_total
            self.insights.append(
                f"[GRAPHIQUE] La catégorie '{cat_top}' représente {pct_top:.0%} du CA total "
                f"— risque de dépendance à surveiller"
            )

        # Insight sur la saisonnalité
        meilleur_mois = kpis.get("meilleur_mois", "?")
        pire_mois = kpis.get("pire_mois", "?")
        self.insights.append(
            f"[CALENDRIER] Saisonnalité marquée : pic en {meilleur_mois}, "
            f"creux en {pire_mois} — adapter les stocks en conséquence"
        )

        # Insight sur le panier moyen
        panier = kpis.get("panier_moyen", 0)
        self.insights.append(
            f"[SHOPPING_TROLLEY] Panier moyen de {panier:.2f}€ — "
            f"levier d'upselling à explorer pour l'augmenter de 10-15%"
        )

    def _generer_rfm_dashboard(self, df):
        """
        Génère le dashboard RFM (repris de la Partie 6).
        Version simplifiée intégrée dans le pipeline.
        """
        from scipy import stats as scipy_stats

        # Calcul RFM
        date_ref = pd.to_datetime(config.DATE_REFERENCE_RFM)
        rfm = df.groupby("customer_id").agg(
            recence     = ("date",     lambda x: (date_ref - x.max()).days),
            frequence   = ("order_id", "count"),
            valeur      = ("revenue",  "sum"),
        ).reset_index()

        # Scoring 1-5 (5 = meilleur)
        rfm["score_R"] = pd.qcut(rfm["recence"],   5, labels=[5,4,3,2,1], duplicates="drop")
        rfm["score_F"] = pd.qcut(rfm["frequence"], 5, labels=[1,2,3,4,5], duplicates="drop")
        rfm["score_M"] = pd.qcut(rfm["valeur"],    5, labels=[1,2,3,4,5], duplicates="drop")

        rfm["score_rfm"] = (rfm["score_R"].astype(int) +
                            rfm["score_F"].astype(int) +
                            rfm["score_M"].astype(int))

        # Segmentation simple
        def segmenter(row):
            r, f, m = int(row["score_R"]), int(row["score_F"]), int(row["score_M"])
            if r >= 4 and f >= 4 and m >= 4:
                return "Champions"
            elif f >= 4:
                return "Loyaux"
            elif r >= 3 and f <= 2:
                return "Potentiels"
            elif r <= 2 and f >= 3:
                return "En risque"
            elif r == 1 and f == 1:
                return "Perdus"
            else:
                return "Intermédiaires"

        rfm["segment"] = rfm.apply(segmenter, axis=1)

        # Visualisation
        couleurs_seg = {
            "Champions"     : "#2ECC71",
            "Loyaux"        : "#3498DB",
            "Potentiels"    : "#F39C12",
            "En risque"     : "#E74C3C",
            "Perdus"        : "#95A5A6",
            "Intermédiaires": "#9B59B6",
        }

        fig, axes = plt.subplots(1, 3, figsize=(18, 6))
        fig.suptitle("Analyse RFM — Segmentation Clients ShopSmart 2023",
                     fontsize=14, fontweight="bold", y=1.02)

        # Graphique 1 : Distribution des segments (barplot horizontal)
        ax1 = axes[0]
        seg_counts = rfm["segment"].value_counts()
        couleurs_bar = [couleurs_seg.get(s, "#95A5A6") for s in seg_counts.index]
        ax1.barh(seg_counts.index, seg_counts.values, color=couleurs_bar)
        ax1.set_title("Nombre de Clients par Segment", fontweight="bold")
        ax1.set_xlabel("Nombre de clients")
        for i, v in enumerate(seg_counts.values):
            ax1.text(v + 1, i, str(v), va="center", fontsize=9)

        # Graphique 2 : CA par segment (barplot)
        ax2 = axes[1]
        ca_seg = rfm.groupby("segment")["valeur"].sum().sort_values(ascending=False)
        couleurs_bar2 = [couleurs_seg.get(s, "#95A5A6") for s in ca_seg.index]
        ax2.bar(ca_seg.index, ca_seg.values / 1000, color=couleurs_bar2)
        ax2.set_title("CA Total par Segment (k€)", fontweight="bold")
        ax2.set_ylabel("CA (milliers €)")
        ax2.tick_params(axis="x", rotation=45)

        # Graphique 3 : Scatter Fréquence vs Valeur, coloré par segment
        ax3 = axes[2]
        for segment, couleur in couleurs_seg.items():
            masque = rfm["segment"] == segment
            if masque.sum() > 0:
                ax3.scatter(
                    rfm.loc[masque, "frequence"],
                    rfm.loc[masque, "valeur"],
                    c=couleur, label=segment, alpha=0.6, s=20
                )
        ax3.set_title("Fréquence vs Valeur Client", fontweight="bold")
        ax3.set_xlabel("Fréquence (nb achats)")
        ax3.set_ylabel("Valeur totale (€)")
        ax3.legend(loc="upper left", fontsize=7)

        plt.tight_layout()
        return fig

    def _generer_analyse_abc(self, df):
        """
        Génère la courbe ABC (Pareto) des produits.
        Rappel Partie 7 : A = 80% du CA, B = 15%, C = 5%
        """
        # CA par produit, trié décroissant
        ca_produit = (df.groupby("product")["revenue"]
                        .sum()
                        .sort_values(ascending=False)
                        .reset_index())
        ca_produit.columns = ["produit", "ca"]

        # Calcul du CA cumulé (en pourcentage)
        ca_produit["ca_cum_pct"] = (ca_produit["ca"].cumsum() /
                                     ca_produit["ca"].sum() * 100)

        # Classification ABC
        def classer(pct_cum):
            if pct_cum <= 80:
                return "A"
            elif pct_cum <= 95:
                return "B"
            else:
                return "C"

        ca_produit["classe"] = ca_produit["ca_cum_pct"].apply(classer)

        couleurs_abc = {"A": "#E74C3C", "B": "#F39C12", "C": "#3498DB"}

        fig, ax = plt.subplots(figsize=(14, 7))

        # Barplot coloré par classe ABC
        couleurs_barres = [couleurs_abc[c] for c in ca_produit["classe"]]
        bars = ax.bar(
            range(len(ca_produit)),
            ca_produit["ca"] / 1000,
            color=couleurs_barres,
            alpha=0.85
        )

        # Courbe cumulative (axe secondaire)
        ax2 = ax.twinx()
        ax2.plot(
            range(len(ca_produit)),
            ca_produit["ca_cum_pct"],
            color="#2C3E50", linewidth=2.5, marker="o", markersize=4,
            label="% CA cumulé"
        )
        ax2.axhline(80, color="red",    linestyle="--", linewidth=1, alpha=0.7)
        ax2.axhline(95, color="orange", linestyle="--", linewidth=1, alpha=0.7)
        ax2.set_ylabel("% CA Cumulé", fontsize=11)
        ax2.set_ylim(0, 105)
        ax2.text(len(ca_produit) - 1, 81, "80%", color="red",    fontsize=9)
        ax2.text(len(ca_produit) - 1, 96, "95%", color="orange", fontsize=9)

        # Étiquettes
        ax.set_xticks(range(len(ca_produit)))
        ax.set_xticklabels(ca_produit["produit"], rotation=45, ha="right", fontsize=8)
        ax.set_xlabel("Produit", fontsize=11)
        ax.set_ylabel("CA (milliers €)", fontsize=11)
        ax.set_title("Analyse ABC des Produits — Courbe de Pareto\nShopSmart SARL 2023",
                     fontsize=13, fontweight="bold")

        # Légende des classes
        from matplotlib.patches import Patch
        legende = [Patch(color=c, label=f"Classe {k}") for k, c in couleurs_abc.items()]
        ax.legend(handles=legende, loc="upper right")

        plt.tight_layout()
        return fig

    def _generer_previsions(self, df):
        """
        Génère les prévisions de CA avec régression linéaire.
        Repris et simplifié de la Partie 7.
        """
        from scipy import stats as scipy_stats

        # CA mensuel
        df["mois"] = df["date"].dt.to_period("M")
        ca_mensuel = df.groupby("mois")["revenue"].sum().reset_index()
        ca_mensuel["mois_num"] = range(len(ca_mensuel))
        ca_mensuel["ca_k"] = ca_mensuel["revenue"] / 1000

        # Régression
        x = ca_mensuel["mois_num"].values
        y = ca_mensuel["ca_k"].values
        pente, intercept, r, p_val, se = scipy_stats.linregress(x, y)

        # Prévisions 3 mois supplémentaires
        n = len(x)
        x_prev = np.arange(n, n + 3)
        y_prev = pente * x_prev + intercept

        # IC 95%
        t_crit = scipy_stats.t.ppf(0.975, df=n - 2)
        x_mean = np.mean(x)
        se_pred = se * np.sqrt(1 + 1/n + (x_prev - x_mean)**2 / np.sum((x - x_mean)**2))
        ic_bas = y_prev - t_crit * se_pred
        ic_haut = y_prev + t_crit * se_pred

        # Noms des mois de prévision
        derniere_periode = ca_mensuel["mois"].iloc[-1]
        mois_prev = [(derniere_periode + i + 1).strftime("%b %Y") for i in range(3)]

        # Visualisation
        fig, ax = plt.subplots(figsize=(14, 6))

        # Données observées
        ax.bar(x, y, color="#3498DB", alpha=0.7, label="CA réel mensuel")
        ax.plot(x, pente * x + intercept, "r--", linewidth=2, label=f"Tendance (R²={r**2:.3f})")

        # Prévisions
        ax.bar(x_prev, y_prev, color="#E74C3C", alpha=0.8, label="Prévision (3 mois)")
        ax.fill_between(x_prev, ic_bas, ic_haut, alpha=0.3, color="#E74C3C",
                        label="IC 95%")

        # Étiquettes des mois réels
        labels_x = [str(m) for m in ca_mensuel["mois"]]
        labels_x += mois_prev
        ax.set_xticks(list(range(n)) + list(x_prev))
        ax.set_xticklabels(labels_x, rotation=45, ha="right", fontsize=8)

        ax.set_title("Prévisions de CA — Régression Linéaire\nShopSmart SARL 2023 -> 2024",
                     fontsize=13, fontweight="bold")
        ax.set_ylabel("CA (milliers €)", fontsize=11)
        ax.set_xlabel("Mois", fontsize=11)
        ax.legend(loc="upper left")
        ax.grid(axis="y", alpha=0.4)

        # Annoter les prévisions
        for xi, yi, mois in zip(x_prev, y_prev, mois_prev):
            ax.annotate(f"{yi:.0f}k€", xy=(xi, yi), xytext=(0, 5),
                        textcoords="offset points", ha="center",
                        fontsize=9, fontweight="bold", color="#C0392B")

        plt.tight_layout()
        return fig

    def _construire_rapport_markdown(self):
        """
        Construit le rapport final en format Markdown.

        Pourquoi Markdown ?
        -> Lisible directement dans GitHub, GitLab, Notion, VS Code
        -> Convertible en HTML, PDF, Word avec des outils comme Pandoc
        -> Format standard pour les rapports techniques
        """
        k = self.kpis
        maintenant = datetime.now().strftime("%d %B %Y à %H:%M")
        entreprise = config.ENTREPRISE

        rapport = f"""# [GRAPHIQUE] Rapport d'Analyse des Ventes 2023
## {entreprise['nom']} — {entreprise['secteur']}

---

> **Généré automatiquement le {maintenant}**
> Analyste : {entreprise['analyste']}
> Période analysée : {entreprise['periode']}

---

## [LISTE] Table des Matières

1. [Résumé Exécutif](#1-résumé-exécutif)
2. [Qualité des Données](#2-qualité-des-données)
3. [KPIs Principaux](#3-kpis-principaux)
4. [Analyse par Catégorie](#4-analyse-par-catégorie)
5. [Saisonnalité](#5-saisonnalité)
6. [Analyse des Retours](#6-analyse-des-retours)
7. [Segmentation Clients (RFM)](#7-segmentation-clients-rfm)
8. [Prévisions](#8-prévisions)
9. [Recommandations Stratégiques](#9-recommandations-stratégiques)
10. [Annexes Techniques](#10-annexes-techniques)

---

## 1. Résumé Exécutif

**{entreprise['nom']}** a réalisé un chiffre d'affaires total de
**{formater_nombre(k.get('ca_total', 0))} €** sur l'année 2023,
généré par **{formater_nombre(k.get('nb_commandes', 0))} commandes**
auprès de **{formater_nombre(k.get('nb_clients', 0))} clients uniques**.

### Points Clés

| Indicateur | Valeur |
|-----------|--------|
| CA Total 2023 | **{formater_nombre(k.get('ca_total', 0))} €** |
| Nombre de commandes | **{formater_nombre(k.get('nb_commandes', 0))}** |
| Clients actifs | **{formater_nombre(k.get('nb_clients', 0))}** |
| Panier moyen | **{k.get('panier_moyen', 0):.2f} €** |
| Taux de retour | **{k.get('taux_retour_global', 0):.1%}** |
| Catégorie n°1 | **{k.get('categorie_top', 'N/A')}** |
| Meilleur mois | **{k.get('meilleur_mois', 'N/A')}** |

### Insights Automatiques

{''.join(f"- {insight}\\n" for insight in self.insights)}

---

## 2. Qualité des Données

| Métrique | Valeur |
|---------|--------|
| Lignes dans le dataset brut | {formater_nombre(k.get('nb_lignes_brutes', 0))} |
| Lignes après nettoyage | {formater_nombre(k.get('nb_lignes_nettoyees', 0))} |
| Lignes supprimées | {formater_nombre(k.get('nb_lignes_supprimees', 0))} |
| Taux de rétention | {k.get('taux_retention_lignes', 1):.1%} |
| Colonnes analysées | {k.get('nb_colonnes', 0)} |

> **Méthode de nettoyage :** Imputation par médiane par groupe pour les valeurs
> manquantes, Winsorisation au 1er/99e percentile pour les outliers,
> suppression des doublons exacts.

---

## 3. KPIs Principaux

### Chiffre d'Affaires

Le CA total de **{formater_nombre(k.get('ca_total', 0))} €** représente
la performance consolidée sur les 12 mois de l'année 2023.

> [HAUSSE] Le CA mensuel moyen est de **{k.get('ca_total', 0)/12:,.0f} €**

### Activité Commerciale

- **{formater_nombre(k.get('nb_commandes', 0))} commandes** traitées en 2023
- Soit une moyenne de **{k.get('nb_commandes', 0)/365:.1f} commandes par jour**
- **{formater_nombre(k.get('nb_clients', 0))} clients distincts** ont passé au moins une commande

### Panier Moyen

Le panier moyen de **{k.get('panier_moyen', 0):.2f} €** reflète une clientèle
à pouvoir d'achat intermédiaire. Un programme de cross-selling ou de bundle
pourrait permettre d'augmenter ce KPI de 10 à 15%.

---

## 4. Analyse par Catégorie

La catégorie **{k.get('categorie_top', 'N/A')}** se classe en tête des ventes
avec un CA de **{formater_nombre(k.get('categorie_top_ca', 0))} €**,
représentant **{k.get('categorie_top_ca', 0) / max(k.get('ca_total', 1), 1):.0%}**
du chiffre d'affaires total.

> [PAPERCLIP] Voir le graphique : `reports/figures/dashboard_complet.png`

---

## 5. Saisonnalité

L'analyse mensuelle révèle une **saisonnalité marquée** :

- [HAUSSE] **Pic d'activité :** {k.get('meilleur_mois', 'N/A')}
- [BAISSE] **Creux d'activité :** {k.get('pire_mois', 'N/A')}

**Recommandation :** Anticiper les stocks et renforcer les équipes logistiques
sur les mois de fort volume. Lancer des promotions agressives sur les mois creux.

> [PAPERCLIP] Voir le graphique : `reports/figures/previsions_ca.png`

---

## 6. Analyse des Retours

Le taux de retour global est de **{k.get('taux_retour_global', 0):.1%}**.

{'[ATTENTION] **ALERTE :** Ce taux est supérieur au seuil acceptable de ' + f"{config.SEUIL_TAUX_RETOUR_ALERTE:.0%}." if k.get('taux_retour_global', 0) > config.SEUIL_TAUX_RETOUR_ALERTE else '[OK] Ce taux est dans les normes acceptables du secteur e-commerce (≤ 15%).'}

La catégorie la plus touchée est **{k.get('categorie_retour_max', 'N/A')}**
avec un taux de **{k.get('taux_retour_max', 0):.1%}**.

**Actions recommandées :**
- Auditer la qualité des produits de la catégorie {k.get('categorie_retour_max', '?')}
- Améliorer les fiches produits (photos, descriptions, tailles)
- Mettre en place un questionnaire post-retour pour identifier les causes

---

## 7. Segmentation Clients (RFM)

L'analyse RFM (Récence, Fréquence, Valeur) permet d'identifier les segments
de clients les plus stratégiques.

> [PAPERCLIP] Voir le dashboard RFM : `reports/figures/rfm_dashboard.png`

### Recommandations par Segment

| Segment | Action Recommandée |
|---------|-------------------|
| Champions | Programme VIP, accès anticipé aux nouveautés, parrainage |
| Loyaux | Carte fidélité, offres personnalisées, upselling |
| Potentiels | Email de bienvenue, offre de 2e achat, onboarding |
| En risque | Campagne de réactivation, offre "Vous nous manquez !" |
| Perdus | Enquête de satisfaction, offre de last chance |

---

## 8. Prévisions

Sur la base de la tendance 2023, les prévisions de CA pour les 3 prochains
mois (T1 2024) sont calculées par régression linéaire avec intervalle de
confiance à 95%.

> [PAPERCLIP] Voir le graphique de prévisions : `reports/figures/previsions_ca.png`

> [ATTENTION] **Limite :** La régression linéaire est un modèle simple. Pour des
> prévisions plus robustes, envisager SARIMA ou Prophet (Meta) qui intègrent
> la saisonnalité de manière native.

---

## 9. Recommandations Stratégiques

Sur la base de l'ensemble des analyses réalisées, voici les **5 recommandations
prioritaires** pour l'équipe de direction :

### [1er] Priorité 1 — Réduire le Taux de Retour

- **Problème :** {k.get('taux_retour_global', 0):.1%} de retours génèrent des coûts logistiques et d'image
- **Action :** Audit qualité sur la catégorie {k.get('categorie_retour_max', '?')}, amélioration des fiches produits
- **ROI estimé :** Réduction de 20% des retours = économie significative sur les coûts logistiques

### [2e] Priorité 2 — Maximiser la Valeur des Champions

- **Problème :** Nos meilleurs clients sont sous-exploités commercialement
- **Action :** Lancer un programme VIP avec avantages exclusifs (livraison prioritaire, accès anticipé, service dédié)
- **ROI estimé :** Augmenter le panier des Champions de 15% = impact CA direct

### [3e] Priorité 3 — Réactiver les Clients "En Risque"

- **Problème :** Des clients autrefois actifs ne commandent plus
- **Action :** Campagne email automatisée avec offre personnalisée basée sur leur historique d'achats
- **ROI estimé :** Réactiver 20% des clients "en risque" = gain substantiel sans coût d'acquisition

### 4⃣  Priorité 4 — Augmenter le Panier Moyen

- **Problème :** Panier moyen de {k.get('panier_moyen', 0):.0f}€ avec potentiel d'augmentation
- **Action :** Recommandations personnalisées ("Les clients ont aussi acheté..."), bundles thématiques
- **ROI estimé :** +10% de panier moyen = +10% de CA sans augmenter le nombre de clients

### 5⃣  Priorité 5 — Anticiper la Saisonnalité

- **Problème :** Stocks sous-optimaux lors des pics (ruptures) et creux (surstock)
- **Action :** Planning d'approvisionnement basé sur les prévisions, campagnes promotionnelles sur les mois creux
- **ROI estimé :** Réduction des ruptures de stock et des coûts de stockage

---

## 10. Annexes Techniques

### Méthodologie

| Étape | Technique Utilisée |
|-------|-------------------|
| Nettoyage | Imputation médiane, Winsorisation 1-99%, déduplication |
| EDA | Statistiques descriptives, corrélations de Pearson et Cramér's V |
| Segmentation | Analyse RFM avec scoring quintile (1-5) |
| Prévisions | Régression linéaire des moindres carrés, IC 95% |
| Classification produits | Analyse ABC (Pareto 80/15/5) |

### Outils & Technologies

```
Python 3.10+
├── pandas 2.x       -> Manipulation des données
├── numpy 1.x        -> Calcul numérique
├── matplotlib 3.x   -> Visualisations statiques
├── seaborn 0.x      -> Graphiques statistiques
├── scipy 1.x        -> Tests statistiques, régression
└── pathlib          -> Gestion des chemins de fichiers
```

### Fichiers Générés

| Fichier | Description |
|---------|-------------|
| `data/sales_data_clean.csv` | Dataset nettoyé (prêt pour analyses futures) |
| `reports/final_report.md` | Ce rapport |
| `reports/figures/dashboard_complet.png` | Dashboard 6 graphiques |
| `reports/figures/rfm_dashboard.png` | Segmentation RFM |
| `reports/figures/analyse_abc.png` | Courbe de Pareto produits |
| `reports/figures/previsions_ca.png` | Prévisions CA T1 2024 |

### Limites & Précautions

1. **Données simulées** : Ce dataset est généré synthétiquement. Les insights
   doivent être validés sur des données réelles avant toute décision stratégique.

2. **Modèle de prévision simple** : La régression linéaire suppose une tendance
   constante. Elle n'intègre pas les événements exceptionnels (promotions, crises).

3. **RFM statique** : L'analyse RFM est calculée à une date fixe. Elle devrait
   être recalculée mensuellement pour rester actionnable.

4. **Causalité vs corrélation** : Les corrélations observées n'impliquent pas
   de causalité. Des tests A/B sont nécessaires avant de déployer des actions.

---

*Rapport généré par DataInsight Pro v1.0 — {entreprise['nom']} — {maintenant}*
*Pour toute question : {entreprise['analyste']}*
"""
        return rapport


# =============================================================================
# POINT D'ENTRÉE PRINCIPAL
# =============================================================================

def parser_arguments():
    """
    Configure les arguments en ligne de commande.

    argparse est la bibliothèque standard Python pour gérer les arguments CLI.
    Exemples d'usage :
      python main.py                        -> pipeline complet
      python main.py --etape nettoyage      -> seulement le nettoyage
      python main.py --rapport-seulement    -> seulement le rapport
      python main.py --aide                 -> afficher l'aide
    """
    parser = argparse.ArgumentParser(
        description="DataInsight Pro — Pipeline d'analyse ShopSmart SARL",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Exemples :
  python main.py                      Pipeline complet
  python main.py --etape chargement   Une seule étape
  python main.py --sans-graphiques    Sauter les visualisations (plus rapide)
        """
    )

    parser.add_argument(
        "--etape",
        choices=["chargement", "nettoyage", "analyse", "visualisation", "rapport"],
        help="Exécuter uniquement cette étape (nécessite les données déjà traitées)"
    )
    parser.add_argument(
        "--sans-graphiques",
        action="store_true",
        help="Sauter la génération des graphiques (plus rapide)"
    )
    parser.add_argument(
        "--rapport-seulement",
        action="store_true",
        help="Générer uniquement le rapport (si les KPIs sont déjà calculés)"
    )
    parser.add_argument(
        "--verbose",
        action="store_true",
        help="Afficher plus de détails durant l'exécution"
    )

    return parser.parse_args()


def main():
    """
    Fonction principale. Lance le pipeline selon les arguments.
    """
    debut = time.time()

    # Parser les arguments CLI
    args = parser_arguments()

    print("=" * 70)
    print("  DATAINSIGHT PRO — PIPELINE D'ANALYSE")
    print(f"  {config.ENTREPRISE['nom']} — {config.ENTREPRISE['periode']}")
    print(f"  Lancé le : {datetime.now().strftime('%d/%m/%Y à %H:%M:%S')}")
    print("=" * 70)

    try:
        # Créer le pipeline
        pipeline = PipelineAnalyse()

        # Valider l'environnement (toujours fait en premier)
        pipeline.valider_environnement()

        if args.rapport_seulement:
            # Cas spécial : uniquement le rapport
            # (nécessite d'avoir déjà lancé le pipeline complet une fois)
            print("[ATTENTION]  Mode rapport seulement : les données doivent déjà être nettoyées.")
            pipeline.charger()
            pipeline.nettoyer()
            pipeline.analyser()
            pipeline.generer_rapport()

        elif args.etape:
            # Exécuter jusqu'à l'étape demandée
            etapes = ["chargement", "nettoyage", "analyse", "visualisation", "rapport"]
            idx_cible = etapes.index(args.etape)

            pipeline.charger()
            if idx_cible >= 1:
                pipeline.nettoyer()
            if idx_cible >= 2:
                pipeline.analyser()
            if idx_cible >= 3 and not args.sans_graphiques:
                pipeline.visualiser()
            if idx_cible >= 4:
                pipeline.generer_rapport()

        else:
            # Pipeline complet (comportement par défaut)
            pipeline.charger()
            pipeline.nettoyer()
            pipeline.analyser()
            if not args.sans_graphiques:
                pipeline.visualiser()
            pipeline.generer_rapport()

        # Afficher le résumé final
        pipeline.afficher_resume_final()

        duree = time.time() - debut
        print(f"\n[TEMPS]  Durée totale du pipeline : {duree:.1f} secondes")
        print("* Succès ! Consultez le dossier reports/ pour vos livrables.")

        return 0   # Code de sortie 0 = succès

    except KeyboardInterrupt:
        print("\n\n[ATTENTION]  Pipeline interrompu par l'utilisateur (Ctrl+C)")
        return 1

    except FileNotFoundError as e:
        print(f"\n[X] ERREUR : Fichier introuvable — {e}")
        print("   -> Vérifiez que vous avez lancé generate_dataset.py")
        return 2

    except Exception as e:
        print(f"\n[X] ERREUR INATTENDUE : {type(e).__name__}: {e}")
        print("\nTrace complète :")
        traceback.print_exc()
        print("\n-> Consultez la trace ci-dessus pour diagnostiquer le problème.")
        return 99


# Ce bloc garantit que main() n'est appelé que si on lance ce fichier directement
# (pas si on fait : from main import PipelineAnalyse)
if __name__ == "__main__":
    code_sortie = main()
    sys.exit(code_sortie)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 45 : TESTS UNITAIRES AVEC pytest
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les tests automatisés sont LA compétence qui distingue un data scientist
junior d'un professionnel confirmé. Un test vérifie qu'une fonction produit
le résultat attendu — automatiquement, à chaque modification du code.

Pourquoi tester ?
  -> Vous modifiez une fonction -> les tests détectent immédiatement si
    vous avez cassé quelque chose ailleurs ("régression")
  -> La confiance : vous pouvez refactoriser sans craindre de tout casser
  -> La documentation : un test montre comment une fonction est censée marcher

────────────────────────────────────────────────────────────────────────────────
FICHIER : tests/__init__.py (vide, mais nécessaire)
────────────────────────────────────────────────────────────────────────────────

# Ce fichier vide indique à Python que le dossier tests/ est un package.
# Sans lui, pytest ne trouve pas les tests dans certaines configurations.

────────────────────────────────────────────────────────────────────────────────
FICHIER : tests/test_pipeline.py
────────────────────────────────────────────────────────────────────────────────

# =============================================================================
# tests/test_pipeline.py — Tests Unitaires du Pipeline DataInsight Pro
#
# Lancer les tests : pytest tests/ -v
# Lancer avec couverture : pytest tests/ --cov=src --cov-report=term-missing
# =============================================================================

import pytest
import pandas as pd
import numpy as np
import sys
from pathlib import Path

# Ajouter le répertoire racine au PYTHONPATH pour trouver nos modules
# (nécessaire car on est dans le sous-dossier tests/)
sys.path.insert(0, str(Path(__file__).parent.parent))

import config
from src.data_loader import charger_donnees, verifier_colonnes
from src.data_cleaning import nettoyer_donnees


# =============================================================================
# FIXTURES pytest — Données de test réutilisables
# =============================================================================
#
# Une "fixture" pytest est une fonction qui prépare des données partagées.
# Le décorateur @pytest.fixture indique à pytest que cette fonction est une fixture.
# Les tests qui ont besoin de ces données mettent le nom de la fixture en argument.
#
# Avantage : les données sont préparées UNE fois et partagées par tous les tests.

@pytest.fixture
def df_minimal():
    """
    Crée un DataFrame minimal mais réaliste pour les tests.
    On utilise des données construites à la main pour contrôler exactement
    ce qu'on teste.
    """
    return pd.DataFrame({
        "order_id"       : ["ORD001", "ORD002", "ORD003", "ORD004", "ORD005"],
        "date"           : pd.to_datetime([
                               "2023-01-15", "2023-02-20", "2023-03-10",
                               "2023-04-05", "2023-05-25"
                           ]),
        "customer_id"    : ["C001", "C002", "C001", "C003", "C002"],
        "customer_age"   : [25.0, np.nan, 32.0, 45.0, 28.0],  # 1 NaN intentionnel
        "region"         : ["Île-de-France", "PACA", "Bretagne", "Normandie", "PACA"],
        "category"       : ["Électronique", "Vêtements", "Électronique",
                            "Maison & Jardin", "Vêtements"],
        "product"        : ["Laptop", "T-shirt", "Laptop", "Lampe", "Jean"],
        "quantity"       : [1, 2, 1, 3, 1],
        "unit_price"     : [899.99, 29.99, 899.99, 45.00, 79.99],
        "payment_method" : ["Carte", "PayPal", "Carte", "Virement", "PayPal"],
        "returned"       : [False, False, True, False, False],
        "revenue"        : [899.99, 59.98, 899.99, 135.00, 79.99],
    })


@pytest.fixture
def df_avec_doublons(df_minimal):
    """
    Étend df_minimal avec un doublon exact (ORD001 en double).
    """
    doublon = df_minimal.iloc[0:1].copy()   # Copie de la première ligne
    return pd.concat([df_minimal, doublon], ignore_index=True)


@pytest.fixture
def df_avec_outlier(df_minimal):
    """
    Étend df_minimal avec une ligne ayant une valeur extrême.
    """
    outlier = pd.DataFrame({
        "order_id"       : ["ORD999"],
        "date"           : pd.to_datetime(["2023-06-01"]),
        "customer_id"    : ["C999"],
        "customer_age"   : [35.0],
        "region"         : ["Île-de-France"],
        "category"       : ["Électronique"],
        "product"        : ["Serveur B2B"],
        "quantity"       : [500],          # <- Outlier extrême
        "unit_price"     : [5000.00],
        "payment_method" : ["Virement"],
        "returned"       : [False],
        "revenue"        : [2500000.00],   # <- 2,5 millions = outlier
    })
    return pd.concat([df_minimal, outlier], ignore_index=True)


# =============================================================================
# TESTS DE CHARGEMENT
# =============================================================================

class TestChargement:
    """
    Groupe de tests pour le module data_loader.
    Utiliser des classes pour regrouper les tests par thème.
    """

    def test_verifier_colonnes_toutes_presentes(self, df_minimal):
        """
        Test : verifier_colonnes retourne [] si toutes les colonnes sont là.
        """
        colonnes_requises = list(df_minimal.columns)
        manquantes = verifier_colonnes(df_minimal, colonnes_requises)

        # assert : vérifie une condition ; si False -> test ÉCHOUE
        assert manquantes == [], (
            f"Aucune colonne ne devrait manquer, mais reçu : {manquantes}"
        )

    def test_verifier_colonnes_manquantes(self, df_minimal):
        """
        Test : verifier_colonnes détecte les colonnes manquantes.
        """
        colonnes_avec_fantome = list(df_minimal.columns) + ["colonne_fantome"]
        manquantes = verifier_colonnes(df_minimal, colonnes_avec_fantome)

        assert "colonne_fantome" in manquantes, (
            "La colonne 'colonne_fantome' aurait dû être détectée comme manquante"
        )

    def test_types_colonnes_date(self, df_minimal):
        """
        Test : la colonne 'date' doit être de type datetime.
        """
        assert pd.api.types.is_datetime64_any_dtype(df_minimal["date"]), (
            "La colonne 'date' doit être de type datetime64"
        )

    def test_revenue_positif(self, df_minimal):
        """
        Test : toutes les valeurs de revenue doivent être positives.
        """
        assert (df_minimal["revenue"] > 0).all(), (
            "Toutes les valeurs de revenue doivent être strictement positives"
        )


# =============================================================================
# TESTS DE NETTOYAGE
# =============================================================================

class TestNettoyage:
    """
    Groupe de tests pour le module data_cleaning.
    """

    def test_suppression_doublons(self, df_avec_doublons):
        """
        Test : nettoyer_donnees doit supprimer les doublons exacts.
        """
        n_avant = len(df_avec_doublons)
        df_clean = nettoyer_donnees(df_avec_doublons.copy())
        n_apres = len(df_clean)

        assert n_apres < n_avant, (
            f"Des doublons auraient dû être supprimés. "
            f"Avant : {n_avant}, Après : {n_apres}"
        )

    def test_imputation_age_nan(self, df_minimal):
        """
        Test : après nettoyage, plus aucun NaN dans customer_age.
        """
        # Vérifier qu'il y a bien des NaN avant (sinon le test ne teste rien)
        assert df_minimal["customer_age"].isna().any(), (
            "Le fixture df_minimal devrait contenir des NaN dans customer_age"
        )

        df_clean = nettoyer_donnees(df_minimal.copy())

        # Vérifier qu'il n'y a plus de NaN après
        assert not df_clean["customer_age"].isna().any(), (
            "Il ne devrait plus y avoir de NaN dans customer_age après nettoyage"
        )

    def test_winsorisation_outliers(self, df_avec_outlier):
        """
        Test : la Winsorisation doit réduire les valeurs extrêmes.
        """
        # Valeur outlier insérée : 2,500,000 €
        max_avant = df_avec_outlier["revenue"].max()

        df_clean = nettoyer_donnees(df_avec_outlier.copy())
        max_apres = df_clean["revenue"].max()

        assert max_apres < max_avant, (
            f"La Winsorisation aurait dû réduire le maximum. "
            f"Avant : {max_avant:,.0f}€, Après : {max_apres:,.0f}€"
        )

    def test_ordre_colonnes_preserve(self, df_minimal):
        """
        Test : le nettoyage ne doit pas supprimer de colonnes.
        """
        colonnes_avant = set(df_minimal.columns)
        df_clean = nettoyer_donnees(df_minimal.copy())
        colonnes_apres = set(df_clean.columns)

        # Les colonnes originales doivent toutes être présentes
        manquantes = colonnes_avant - colonnes_apres
        assert not manquantes, (
            f"Ces colonnes ont disparu après nettoyage : {manquantes}"
        )


# =============================================================================
# TESTS D'ANALYSE
# =============================================================================

class TestAnalyse:
    """
    Tests pour les calculs analytiques.
    On teste les calculs directement, sans passer par les modules.
    """

    def test_calcul_revenue_total(self, df_minimal):
        """
        Test : la somme du revenue correspond à la valeur attendue.
        """
        # Calcul attendu : 899.99 + 59.98 + 899.99 + 135.00 + 79.99
        attendu = 899.99 + 59.98 + 899.99 + 135.00 + 79.99
        calcule = df_minimal["revenue"].sum()

        # pytest.approx gère les erreurs d'arrondi des flottants
        # (0.001 + 0.002 n'est pas exactement 0.003 en virgule flottante !)
        assert calcule == pytest.approx(attendu, rel=1e-5), (
            f"Revenue total incorrect. Attendu : {attendu:.2f}, Calculé : {calcule:.2f}"
        )

    def test_clients_uniques(self, df_minimal):
        """
        Test : le nombre de clients uniques est correct.
        """
        # Dans df_minimal : C001, C002, C003 = 3 clients uniques
        nb_clients = df_minimal["customer_id"].nunique()
        assert nb_clients == 3, f"Attendu 3 clients uniques, obtenu {nb_clients}"

    def test_taux_retour_calcul(self, df_minimal):
        """
        Test : le taux de retour est calculé correctement.
        """
        # Dans df_minimal : 1 retour sur 5 commandes = 20%
        taux = df_minimal["returned"].mean()
        assert taux == pytest.approx(0.20, rel=1e-5), (
            f"Taux de retour incorrect. Attendu : 20%, Calculé : {taux:.1%}"
        )

    def test_panier_moyen(self, df_minimal):
        """
        Test : le panier moyen est calculé correctement.
        """
        attendu = df_minimal["revenue"].sum() / len(df_minimal)
        calcule = df_minimal["revenue"].mean()
        assert calcule == pytest.approx(attendu, rel=1e-5)

    def test_categorie_dominante(self, df_minimal):
        """
        Test : la catégorie avec le plus de CA est correctement identifiée.
        """
        ca_par_cat = df_minimal.groupby("category")["revenue"].sum()
        categorie_top = ca_par_cat.idxmax()

        # Dans df_minimal, Électronique (899.99 + 899.99 = 1799.98) domine
        assert categorie_top == "Électronique", (
            f"Catégorie dominante incorrecte. Attendu : 'Électronique', Obtenu : '{categorie_top}'"
        )


# =============================================================================
# TESTS DE CONFIGURATION
# =============================================================================

class TestConfiguration:
    """
    Tests pour vérifier que la configuration est cohérente.
    """

    def test_seuils_coherents(self):
        """
        Test : le percentile bas < percentile haut.
        """
        assert config.PERCENTILE_BAS < config.PERCENTILE_HAUT, (
            "PERCENTILE_BAS doit être inférieur à PERCENTILE_HAUT"
        )

    def test_seuil_retour_valide(self):
        """
        Test : le seuil de taux de retour est entre 0 et 1.
        """
        assert 0 < config.SEUIL_TAUX_RETOUR_ALERTE < 1, (
            "SEUIL_TAUX_RETOUR_ALERTE doit être une proportion entre 0 et 1"
        )

    def test_couleurs_categories_completes(self):
        """
        Test : toutes les catégories de produits ont une couleur définie.
        """
        categories_attendues = {"Électronique", "Vêtements",
                                "Maison & Jardin", "Beauté & Santé"}
        categories_configurees = set(config.COULEURS_CATEGORIES.keys())

        manquantes = categories_attendues - categories_configurees
        assert not manquantes, (
            f"Ces catégories n'ont pas de couleur configurée : {manquantes}"
        )

    def test_colonnes_attendues_non_vides(self):
        """
        Test : la liste des colonnes attendues n'est pas vide.
        """
        assert len(config.COLONNES_ATTENDUES) > 0, (
            "COLONNES_ATTENDUES ne doit pas être vide"
        )


# =============================================================================
# TESTS PARAMÉTRÉS — Tester plusieurs cas d'un coup
# =============================================================================

@pytest.mark.parametrize("category,expected_min_revenue", [
    ("Électronique",    500.0),   # Les produits électroniques coûtent > 500€
    ("Vêtements",        20.0),   # Les vêtements coûtent > 20€
    ("Maison & Jardin",  30.0),   # L'ameublement coûte > 30€
])
def test_revenue_minimum_par_categorie(df_minimal, category, expected_min_revenue):
    """
    Test paramétré : vérifie que le revenue minimum par catégorie
    respecte des seuils métier.

    @pytest.mark.parametrize permet de lancer le même test avec
    différentes valeurs — au lieu d'écrire 3 fonctions de test séparées.
    """
    df_cat = df_minimal[df_minimal["category"] == category]

    if len(df_cat) == 0:
        pytest.skip(f"Aucune donnée pour la catégorie {category} dans ce fixture")

    revenue_min = df_cat["revenue"].min()
    assert revenue_min >= expected_min_revenue, (
        f"Catégorie '{category}' : revenue minimum {revenue_min:.2f}€ "
        f"< seuil attendu {expected_min_revenue:.2f}€"
    )


# =============================================================================
# TESTS D'EXCEPTION — Vérifier que les erreurs sont bien levées
# =============================================================================

class TestExceptions:
    """
    Tester que le code lève les bonnes exceptions dans les cas d'erreur.
    C'est aussi important de tester les cas d'échec que les cas de succès !
    """

    def test_charger_fichier_inexistant(self):
        """
        Test : charger un fichier inexistant doit lever une exception.
        pytest.raises() vérifie qu'une exception est bien levée.
        """
        with pytest.raises((FileNotFoundError, Exception)):
            charger_donnees("/chemin/inexistant/fichier.csv")

    def test_verifier_colonnes_dataframe_vide(self):
        """
        Test : verifier_colonnes sur un DataFrame vide doit retourner
        toutes les colonnes requises comme manquantes.
        """
        df_vide = pd.DataFrame()
        colonnes_requises = ["col1", "col2", "col3"]
        manquantes = verifier_colonnes(df_vide, colonnes_requises)

        assert set(manquantes) == set(colonnes_requises), (
            "Toutes les colonnes devraient être manquantes sur un DataFrame vide"
        )

────────────────────────────────────────────────────────────────────────────────
COMMENT LANCER LES TESTS
────────────────────────────────────────────────────────────────────────────────

Depuis le répertoire racine du projet :

# Lancer tous les tests avec affichage détaillé
pytest tests/ -v

# Lancer avec rapport de couverture (quelles lignes de code sont testées)
pip install pytest-cov
pytest tests/ --cov=src --cov-report=term-missing

# Lancer un seul fichier de test
pytest tests/test_pipeline.py -v

# Lancer une seule classe de tests
pytest tests/test_pipeline.py::TestNettoyage -v

# Lancer un seul test spécifique
pytest tests/test_pipeline.py::TestAnalyse::test_calcul_revenue_total -v

# Arrêter dès le premier échec
pytest tests/ -v -x

────────────────────────────────────────────────────────────────────────────────
COMPRENDRE LA SORTIE pytest
────────────────────────────────────────────────────────────────────────────────

$ pytest tests/ -v

============================= test session starts ==============================
platform linux -- Python 3.10.12, pytest-7.4.0
collected 18 items

tests/test_pipeline.py::TestChargement::test_verifier_colonnes_toutes_presentes PASSED  [ 5%]
tests/test_pipeline.py::TestChargement::test_verifier_colonnes_manquantes       PASSED  [11%]
tests/test_pipeline.py::TestChargement::test_types_colonnes_date                PASSED  [16%]
tests/test_pipeline.py::TestChargement::test_revenue_positif                    PASSED  [22%]
tests/test_pipeline.py::TestNettoyage::test_suppression_doublons                PASSED  [27%]
tests/test_pipeline.py::TestNettoyage::test_imputation_age_nan                  PASSED  [33%]
tests/test_pipeline.py::TestNettoyage::test_winsorisation_outliers              PASSED  [38%]
tests/test_pipeline.py::TestNettoyage::test_ordre_colonnes_preserve             PASSED  [44%]
tests/test_pipeline.py::TestAnalyse::test_calcul_revenue_total                  PASSED  [50%]
...

========================= 18 passed in 2.34s ==================================

Chaque ligne PASSED = [OK] ce test fonctionne correctement.
Si un test FAILED -> pytest vous indique exactement quelle assertion a échoué
et pourquoi. C'est votre filet de sécurité !

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 46 : README.md — LA VITRINE DE VOTRE PROJET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le README est la première chose que voit quelqu'un qui découvre votre projet
(sur GitHub, par exemple). Il doit répondre en 30 secondes à : "Qu'est-ce que
c'est ? Comment ça marche ? Comment je l'utilise ?"

────────────────────────────────────────────────────────────────────────────────
FICHIER : README.md
────────────────────────────────────────────────────────────────────────────────

# [GRAPHIQUE] DataInsight Pro — Analyse des Ventes ShopSmart SARL

> Pipeline complet d'analyse de données Python pour l'e-commerce.
> Développé dans le cadre du cours DataInsight Pro — Niveau Débutant à Intermédiaire.

---

## [OBJECTIF] Présentation

**DataInsight Pro** est un projet d'analyse de données complet appliqué à
**ShopSmart SARL**, une entreprise française d'e-commerce fictive.

Il couvre l'ensemble du cycle de vie d'un projet data :
- [ENTREE] **Ingestion** : chargement et validation du dataset
- [NETTOYAGE] **Nettoyage** : traitement des valeurs manquantes, doublons, outliers
- [RECHERCHE] **Exploration (EDA)** : statistiques, corrélations, saisonnalité
- [GRAPHIQUE] **Visualisation** : dashboard complet, graphiques professionnels
- [UTILISATEURS] **Segmentation** : analyse RFM des clients
- [HAUSSE] **Prévisions** : régression linéaire avec intervalles de confiance
- [NOTE] **Rapport** : génération automatique d'un rapport Markdown

---

## [RAPIDE] Démarrage Rapide

### 1. Prérequis

```bash
Python 3.10 ou supérieur
```

### 2. Installation des dépendances

```bash
pip install -r requirements.txt
```

### 3. Générer le dataset (une seule fois)

```bash
python generate_dataset.py
```

### 4. Lancer le pipeline complet

```bash
python main.py
```

### 5. Consulter les résultats

```
reports/
├── final_report.md          <- Rapport Markdown complet
└── figures/
    ├── dashboard_complet.png
    ├── rfm_dashboard.png
    ├── analyse_abc.png
    └── previsions_ca.png
```

---

## [DOSSIER] Structure du Projet

```
python_data_project/
│
├── [DOSSIER] data/
│   ├── sales_data.csv              <- Dataset brut (12 000 lignes)
│   └── sales_data_clean.csv        <- Dataset nettoyé (généré automatiquement)
│
├── [DOSSIER] src/
│   ├── data_loader.py              <- Chargement et validation
│   ├── data_cleaning.py            <- Nettoyage des données
│   ├── analysis.py                 <- EDA et KPIs
│   ├── visualization.py            <- Graphiques Matplotlib
│   └── utils.py                    <- Fonctions utilitaires
│
├── [DOSSIER] reports/
│   ├── final_report.md             <- Rapport final automatisé
│   └── figures/                    <- Graphiques PNG/PDF
│
├── [DOSSIER] tests/
│   └── test_pipeline.py            <- Tests unitaires pytest
│
├── config.py                       <- Configuration centralisée
├── main.py                         <- Point d'entrée principal
├── generate_dataset.py             <- Génération du dataset fictif
├── requirements.txt                <- Dépendances Python
└── README.md                       <- Ce fichier
```

---

## [CONFIG] Options du Pipeline

```bash
# Pipeline complet (recommandé)
python main.py

# Seulement le nettoyage
python main.py --etape nettoyage

# Sans graphiques (plus rapide)
python main.py --sans-graphiques

# Aide complète
python main.py --help
```

---

## [TEST] Lancer les Tests

```bash
# Installer pytest
pip install pytest pytest-cov

# Lancer tous les tests
pytest tests/ -v

# Avec rapport de couverture de code
pytest tests/ --cov=src --cov-report=term-missing
```

---

## [PACKAGE] Dépendances

| Bibliothèque | Version | Usage |
|-------------|---------|-------|
| pandas | ≥ 2.0 | Manipulation des données |
| numpy | ≥ 1.24 | Calcul numérique |
| matplotlib | ≥ 3.7 | Visualisations |
| seaborn | ≥ 0.12 | Graphiques statistiques |
| scipy | ≥ 1.10 | Tests statistiques, régression |
| pytest | ≥ 7.0 | Tests unitaires |

---

## [GRAPHIQUE] Dataset

Le dataset simulé représente les ventes 2023 de ShopSmart SARL :

| Propriété | Valeur |
|-----------|--------|
| Lignes | 12 000 (+ 50 doublons intentionnels) |
| Colonnes | 12 |
| Période | 01/01/2023 -> 31/12/2023 |
| Catégories | Électronique, Vêtements, Maison & Jardin, Beauté & Santé |
| Régions | Île-de-France, PACA, Bretagne, Normandie, Occitanie |
| Graine aléatoire | 42 (reproductible) |

**Problèmes de qualité intentionnels (pour l'apprentissage) :**
- 360 valeurs manquantes dans `customer_age`
- 240 valeurs manquantes dans `unit_price`
- 50 doublons exacts
- 30 commandes B2B avec quantités atypiques (outliers)

---

## [COURS] Contexte Pédagogique

Ce projet fait partie du cours **DataInsight Pro** (8 parties) :

| Partie | Thème |
|--------|-------|
| 1 | Setup, dataset, structure de projet |
| 2 | Pandas : chargement et manipulation |
| 3 | Nettoyage des données |
| 4 | Analyse exploratoire (EDA) |
| 5 | Visualisation avec Matplotlib |
| 6 | Analyses avancées (RFM, cohortes, panier) |
| 7 | Cas business réel (4 questions stratégiques) |
| **8** | **Projet final intégré (ce projet)** |

---

## [UTILISATEURS] Auteur & Licence

- **Analyste** : Équipe Data Analytics — ShopSmart SARL
- **Cours** : DataInsight Pro
- **Licence** : MIT (libre utilisation et modification)

---

*Dernière mise à jour : 2024 — DataInsight Pro v1.0*

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 47 : EXERCICES FINAUX ET SOLUTIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Ces exercices consolident l'ensemble des compétences acquises sur les 8 parties.

────────────────────────────────────────────────────────────────────────────────
EXERCICE 1 (***) — Ajouter un paramètre à config.py
────────────────────────────────────────────────────────────────────────────────

Contexte :
  L'équipe marketing vous demande d'identifier les "clients VIP" comme étant
  le top 5% par CA (au lieu du top 10% actuellement configuré).

Tâche :
  1. Modifiez SEUIL_CLIENT_VIP_PERCENTILE dans config.py pour passer à 0.05
  2. Ajoutez un nouveau paramètre : NB_MOIS_PREVISION = 6 (pour prévoir 6 mois
     au lieu de 3 dans les prévisions)
  3. Vérifiez que python config.py s'exécute sans erreur

SOLUTION :

Dans config.py, modifiez :

  # Avant :
  SEUIL_CLIENT_VIP_PERCENTILE = 0.10

  # Après :
  SEUIL_CLIENT_VIP_PERCENTILE = 0.05   # Top 5% = clients VIP

Et ajoutez :

  # Nombre de mois à prévoir
  NB_MOIS_PREVISION = 6   # Était codé en dur à 3 dans main.py

Puis dans main.py, dans _generer_previsions(), remplacez :
  x_prev = np.arange(n, n + 3)
par :
  x_prev = np.arange(n, n + config.NB_MOIS_PREVISION)

Et les noms des mois :
  mois_prev = [(derniere_periode + i + 1).strftime("%b %Y")
               for i in range(config.NB_MOIS_PREVISION)]

Bonne pratique : tout paramètre "magique" (nombre fixé en dur) doit être
extrait dans config.py. Cela rend le code plus maintenable et testable.

────────────────────────────────────────────────────────────────────────────────
EXERCICE 2 (***) — Écrire un nouveau test unitaire
────────────────────────────────────────────────────────────────────────────────

Contexte :
  Vous avez ajouté une fonction calculer_ltv_client(df) dans src/analysis.py
  qui calcule la LTV (Life Time Value) de chaque client :
    LTV = CA total du client / durée en mois de son activité

Tâche :
  Écrivez un test unitaire dans tests/test_pipeline.py qui vérifie que :
  1. La LTV est toujours positive (> 0)
  2. La LTV du client avec le plus grand CA est >= à la LTV moyenne
  3. La fonction retourne bien un DataFrame avec les colonnes 'customer_id' et 'ltv'

SOLUTION :

class TestLTV:

    def test_ltv_positif(self, df_minimal):
        """La LTV doit toujours être positive."""
        from src.analysis import calculer_ltv_client
        df_ltv = calculer_ltv_client(df_minimal)

        assert (df_ltv["ltv"] > 0).all(), (
            "Toutes les LTV doivent être strictement positives"
        )

    def test_ltv_top_client(self, df_minimal):
        """Le client avec le plus grand CA doit avoir une LTV >= moyenne."""
        from src.analysis import calculer_ltv_client

        # Trouver le client avec le plus grand CA
        ca_par_client = df_minimal.groupby("customer_id")["revenue"].sum()
        top_client = ca_par_client.idxmax()

        df_ltv = calculer_ltv_client(df_minimal)
        ltv_top = df_ltv.loc[df_ltv["customer_id"] == top_client, "ltv"].values[0]
        ltv_moy = df_ltv["ltv"].mean()

        assert ltv_top >= ltv_moy, (
            f"Le top client devrait avoir une LTV ({ltv_top:.2f}) >= LTV moyenne ({ltv_moy:.2f})"
        )

    def test_ltv_structure(self, df_minimal):
        """La fonction doit retourner un DataFrame avec les bonnes colonnes."""
        from src.analysis import calculer_ltv_client
        df_ltv = calculer_ltv_client(df_minimal)

        assert isinstance(df_ltv, pd.DataFrame), "Le résultat doit être un DataFrame"
        assert "customer_id" in df_ltv.columns, "Colonne 'customer_id' manquante"
        assert "ltv" in df_ltv.columns, "Colonne 'ltv' manquante"
        assert len(df_ltv) == df_minimal["customer_id"].nunique(), (
            "Une ligne par client unique"
        )

────────────────────────────────────────────────────────────────────────────────
EXERCICE 3 (***) — Ajouter une étape au pipeline
────────────────────────────────────────────────────────────────────────────────

Contexte :
  La direction veut un export Excel automatique avec 3 onglets :
    - "KPIs" : tableau des indicateurs clés
    - "Top Produits" : les 10 produits par CA
    - "Clients VIP" : les 50 clients avec le plus grand CA

Tâche :
  Ajoutez une méthode exporter_excel(self) à la classe PipelineAnalyse
  dans main.py. Cette méthode doit :
  1. Créer un fichier reports/export_shopsmart_2023.xlsx
  2. Remplir les 3 onglets
  3. S'intégrer dans le pipeline après .generer_rapport()

SOLUTION :

# Dans config.py, ajoutez :
FICHIER_EXPORT_EXCEL = DOSSIER_RAPPORTS / "export_shopsmart_2023.xlsx"

# Dans main.py, dans la classe PipelineAnalyse, ajoutez :

@mesurer_temps
def exporter_excel(self):
    """
    Exporte les résultats clés dans un fichier Excel multi-onglets.
    """
    afficher_separateur("ÉTAPE 6 : EXPORT EXCEL")

    if self.df_propre is None:
        raise RuntimeError("Appelez .nettoyer() avant .exporter_excel()")

    df = self.df_propre

    # Préparer les données des 3 onglets
    # Onglet 1 : KPIs sous forme de tableau clé/valeur
    df_kpis = pd.DataFrame([
        {"Indicateur": "CA Total (€)",      "Valeur": f"{self.kpis.get('ca_total', 0):,.2f}"},
        {"Indicateur": "Nb Commandes",       "Valeur": f"{self.kpis.get('nb_commandes', 0):,}"},
        {"Indicateur": "Nb Clients",         "Valeur": f"{self.kpis.get('nb_clients', 0):,}"},
        {"Indicateur": "Panier Moyen (€)",   "Valeur": f"{self.kpis.get('panier_moyen', 0):.2f}"},
        {"Indicateur": "Taux de Retour",     "Valeur": f"{self.kpis.get('taux_retour_global', 0):.1%}"},
        {"Indicateur": "Catégorie n°1",      "Valeur": self.kpis.get('categorie_top', 'N/A')},
        {"Indicateur": "Meilleur Mois",      "Valeur": self.kpis.get('meilleur_mois', 'N/A')},
    ])

    # Onglet 2 : Top 10 produits
    top_produits = (df.groupby("product")
                      .agg(
                          ca_total   = ("revenue",  "sum"),
                          nb_ventes  = ("order_id", "count"),
                          prix_moyen = ("unit_price","mean"),
                      )
                      .sort_values("ca_total", ascending=False)
                      .head(10)
                      .reset_index())
    top_produits.columns = ["Produit", "CA Total (€)", "Nb Ventes", "Prix Moyen (€)"]

    # Onglet 3 : Top 50 clients VIP
    clients_vip = (df.groupby("customer_id")
                     .agg(
                         ca_total      = ("revenue",  "sum"),
                         nb_commandes  = ("order_id", "count"),
                         premier_achat = ("date",     "min"),
                         dernier_achat = ("date",     "max"),
                     )
                     .sort_values("ca_total", ascending=False)
                     .head(50)
                     .reset_index())
    clients_vip.columns = ["Client ID", "CA Total (€)", "Nb Commandes",
                           "Premier Achat", "Dernier Achat"]

    # Écrire le fichier Excel avec pd.ExcelWriter
    # ExcelWriter permet d'écrire plusieurs onglets dans un même fichier
    with pd.ExcelWriter(
        config.FICHIER_EXPORT_EXCEL,
        engine="openpyxl",    # Moteur Excel (pip install openpyxl)
        datetime_format="DD/MM/YYYY"
    ) as writer:

        # Écrire chaque DataFrame dans son onglet
        df_kpis.to_excel(writer, sheet_name="KPIs",         index=False)
        top_produits.to_excel(writer, sheet_name="Top Produits",  index=False)
        clients_vip.to_excel(writer, sheet_name="Clients VIP",    index=False)

        # Optionnel : ajuster la largeur des colonnes automatiquement
        for sheet_name, df_sheet in [
            ("KPIs", df_kpis),
            ("Top Produits", top_produits),
            ("Clients VIP", clients_vip)
        ]:
            ws = writer.sheets[sheet_name]
            for col_idx, col in enumerate(df_sheet.columns, 1):
                # Largeur = max(longueur header, longueur max des données) + marge
                max_len = max(
                    len(str(col)),
                    df_sheet[col].astype(str).str.len().max()
                ) + 2
                # Excel utilise des lettres pour les colonnes : A, B, C...
                from openpyxl.utils import get_column_letter
                ws.column_dimensions[get_column_letter(col_idx)].width = min(max_len, 40)

    print(f"  [OK] Export Excel généré : {config.FICHIER_EXPORT_EXCEL}")
    print(f"     Onglets : KPIs | Top Produits | Clients VIP")

    self.etapes_realisees.append("export_excel")
    return self

# Dans main(), ajoutez dans le pipeline complet :
# pipeline.generer_rapport().exporter_excel()

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 48 : BILAN ET PROCHAINES ÉTAPES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

────────────────────────────────────────────────────────────────────────────────
CE QUE VOUS AVEZ ACCOMPLI
────────────────────────────────────────────────────────────────────────────────

En 8 parties et 48 chapitres, vous avez construit de zéro un projet data
professionnel complet. Voici le bilan de vos acquis :

PYTHON FONDAMENTAL
  [OK] Variables, types, structures de contrôle
  [OK] Fonctions, décorateurs (@mesurer_temps)
  [OK] Classes et programmation orientée objet (class PipelineAnalyse)
  [OK] Gestion des erreurs (try/except, sys.exit)
  [OK] Arguments CLI avec argparse
  [OK] pathlib pour la gestion des chemins
  [OK] Modules et imports (from src.module import fonction)

PANDAS
  [OK] Chargement CSV, exploration (.info(), .describe())
  [OK] Sélection : .loc, .iloc, boolean filtering
  [OK] Feature engineering (nouvelles colonnes)
  [OK] GroupBy : agg(), transform(), filter(), apply()
  [OK] Pivot tables, crosstab, stack/unstack, melt
  [OK] Manipulation temporelle (to_period, resample)
  [OK] Export CSV, Excel (to_csv, ExcelWriter)

NETTOYAGE DE DONNÉES
  [OK] Détection et imputation des NaN
  [OK] Suppression des doublons
  [OK] Détection et traitement des outliers (Winsorisation)
  [OK] Rapport de qualité automatisé

ANALYSE STATISTIQUE
  [OK] Statistiques descriptives (moyenne, médiane, variance)
  [OK] Corrélations (Pearson, Cramér's V)
  [OK] Tests statistiques (t-test, Mann-Whitney, ANOVA, Kruskal-Wallis)
  [OK] Régression linéaire avec IC 95%
  [OK] Analyse RFM et segmentation clients
  [OK] Analyse de cohortes et rétention
  [OK] Analyse de panier (Market Basket)
  [OK] Analyse ABC / Pareto

VISUALISATION
  [OK] Interface OO Matplotlib (fig, ax)
  [OK] 12+ types de graphiques (bar, line, scatter, heatmap, boxplot, donut...)
  [OK] Dashboard multi-graphiques avec GridSpec
  [OK] Export PNG/PDF haute résolution (300 DPI)
  [OK] Graphiques interactifs avec Plotly

INGÉNIERIE LOGICIELLE
  [OK] Architecture modulaire (SRP : une responsabilité par module)
  [OK] Configuration centralisée (config.py)
  [OK] Tests unitaires avec pytest (fixtures, paramétrisation, exceptions)
  [OK] Pipeline orienté objet avec chaînage de méthodes
  [OK] README professionnel
  [OK] Rapport automatisé en Markdown

────────────────────────────────────────────────────────────────────────────────
VOS PROCHAINES ÉTAPES — FEUILLE DE ROUTE
────────────────────────────────────────────────────────────────────────────────

Vous avez maintenant des bases solides. Voici comment progresser selon
l'objectif que vous visez :

━━ SI VOUS VISEZ DATA ANALYST ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  1. SQL (indispensable)
     -> Cours : "SQL for Data Analysis" (Mode Analytics ou SQLZoo)
     -> Pratique : PostgreSQL + DBeaver

  2. Tableaux de bord interactifs
     -> Apprendre Tableau Public (gratuit) ou Power BI
     -> Ou Plotly Dash / Streamlit pour rester en Python

  3. Statistiques appliquées
     -> A/B testing, intervalles de confiance, p-values
     -> Livre : "Practical Statistics for Data Scientists" (O'Reilly)

  4. Portfolio
     -> Publier ce projet sur GitHub avec un README soigné
     -> Ajouter 2-3 analyses sur des datasets publics (Kaggle)

━━ SI VOUS VISEZ DATA SCIENTIST ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  1. Machine Learning avec scikit-learn
     -> Régression, classification, clustering
     -> Cours : "Hands-On Machine Learning" (Aurélien Géron)

  2. Séries temporelles avancées
     -> SARIMA, Prophet (Meta), LSTM pour les prévisions
     -> Application : prévisions de ventes long terme

  3. Feature Engineering avancé
     -> Encodage, normalisation, sélection de features

  4. MLflow ou DVC pour le tracking d'expériences
     -> Versionner vos modèles comme vous versionnez votre code

━━ SI VOUS VISEZ DATA ENGINEER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  1. Apache Airflow (orchestration de pipelines)
     -> Automatiser le lancement de main.py selon un planning

  2. Bases de données et SQL avancé
     -> PostgreSQL, BigQuery, Snowflake

  3. Spark (pour les grands volumes)
     -> PySpark : pandas à l'échelle des téraoctets

  4. Docker (conteneurisation)
     -> Packager ce projet pour qu'il tourne partout

━━ RESSOURCES RECOMMANDÉES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  LIVRES
  -> "Python for Data Analysis" — Wes McKinney (créateur de pandas)
  -> "Storytelling with Data" — Cole Nussbaumer Knaflic
  -> "The Data Warehouse Toolkit" — Kimball & Ross (pour la BI)

  PLATEFORMES
  -> Kaggle (compétitions + datasets + cours gratuits)
  -> DataCamp (cours interactifs Python/SQL/R)
  -> Coursera : "IBM Data Science Professional Certificate"

  COMMUNAUTÉS
  -> Reddit : r/datascience, r/learnpython
  -> Discord : Pythonista, Data Science Community
  -> LinkedIn : publier vos projets !

────────────────────────────────────────────────────────────────────────────────
MOT DE FIN
────────────────────────────────────────────────────────────────────────────────

Quand vous avez commencé la Partie 1, vous ne saviez peut-être pas ce qu'était
un DataFrame. Aujourd'hui, vous avez un projet Python professionnel complet,
structuré, testé, documenté, et prêt à être montré à n'importe quel recruteur
ou client.

Ce projet n'est pas seulement du code — c'est une démonstration de votre
capacité à penser comme un data professional :
  -> Structurer un problème complexe en étapes claires
  -> Produire du code maintenable et testable
  -> Communiquer vos résultats à des non-techniciens

La data science n'est pas que des algorithmes complexes. C'est avant tout
la capacité à transformer des données brutes en décisions business.

Vous savez maintenant faire ça.

Bonne continuation dans votre parcours data ! [RAPIDE]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RÉCAPITULATIF DE LA PARTIE 8
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  FICHIERS CRÉÉS DANS CETTE PARTIE :
  [OK] config.py                 — Configuration centralisée (chemins, paramètres)
  [OK] main.py (final)           — Pipeline intégral avec classe PipelineAnalyse
  [OK] tests/__init__.py          — Package de tests
  [OK] tests/test_pipeline.py    — 18 tests unitaires avec pytest
  [OK] README.md                 — Documentation professionnelle du projet
  [OK] reports/final_report.md   — Rapport Markdown généré automatiquement

  CONCEPTS ABORDÉS :
  [OK] pathlib.Path pour la gestion des chemins
  [OK] argparse pour les arguments CLI
  [OK] Classe Pipeline (design pattern)
  [OK] Tests unitaires pytest : fixtures, parametrize, raises
  [OK] Rapport Markdown automatisé
  [OK] Architecture complète d'un projet data professionnel
  [OK] Prochaines étapes et feuille de route

  POUR ALLER PLUS LOIN :
  -> Publier ce projet sur GitHub
  -> Ajouter GitHub Actions pour lancer les tests automatiquement
  -> Déployer un dashboard interactif avec Streamlit
  -> Connecter à une vraie base de données PostgreSQL

================================================================================
FIN DE DATAINSIGHT PRO — PARTIE 8 (FINALE)
================================================================================
Félicitations pour avoir complété l'intégralité du cours DataInsight Pro !
ShopSmart SARL est fictif, mais vos compétences sont bien réelles. [COURS]
================================================================================